Skip to content

Commit c52b13d

Browse files
committed
fix: treat placeholders as visual element
This changes how we deal with placeholders. Currently, a placeholder behaves as a visual hint of what we want the user to type. It also behaves as an optional default value, in that you can press `<Enter>` or `<Tab>` to insert it as the value. This is confusing since it means it is a "partial" default value, and a placeholder at the same time. This change basically removes the placeholder logic from core, such that the `text` and `autocomplete` prompts use `placeholder` purely as a render/visual hint. This updated behaviour means we render the placeholder when no value is set, but it can no longer be inserted as a value. To achieve that, you should combine `defaultValue` with `placeholder` (i.e. set them to the same value).
1 parent f90f47d commit c52b13d

File tree

8 files changed

+55
-77
lines changed

8 files changed

+55
-77
lines changed

packages/core/src/prompts/autocomplete.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
8484
this.options = opts.options;
8585
this.filteredOptions = [...this.options];
8686
this.multiple = opts.multiple === true;
87-
this._usePlaceholderAsValue = false;
8887
this.#filterFn = opts.filter ?? defaultFilter;
8988
let initialValues: unknown[] | undefined;
9089
if (opts.initialValue && Array.isArray(opts.initialValue)) {

packages/core/src/prompts/prompt.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ import type { Action } from '../utils/index.js';
1212

1313
export interface PromptOptions<Self extends Prompt> {
1414
render(this: Omit<Self, 'prompt'>): string | undefined;
15-
placeholder?: string;
1615
initialValue?: any;
17-
defaultValue?: any;
1816
validate?: ((value: any) => string | Error | undefined) | undefined;
1917
input?: Readable;
2018
output?: Writable;
@@ -34,7 +32,6 @@ export default class Prompt {
3432
private _prevFrame = '';
3533
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
3634
protected _cursor = 0;
37-
protected _usePlaceholderAsValue = true;
3835

3936
public state: ClackState = 'initial';
4037
public error = '';
@@ -212,25 +209,11 @@ export default class Prompt {
212209
if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
213210
this.emit('confirm', char.toLowerCase() === 'y');
214211
}
215-
if (this._usePlaceholderAsValue && char === '\t' && this.opts.placeholder) {
216-
if (!this.value) {
217-
this.rl?.write(this.opts.placeholder);
218-
this._setValue(this.opts.placeholder);
219-
}
220-
}
221212

222213
// Call the key event handler and emit the key event
223214
this.emit('key', char?.toLowerCase(), key);
224215

225216
if (key?.name === 'return') {
226-
if (!this.value) {
227-
if (this.opts.defaultValue) {
228-
this._setValue(this.opts.defaultValue);
229-
} else {
230-
this._setValue('');
231-
}
232-
}
233-
234217
if (this.opts.validate) {
235218
const problem = this.opts.validate(this.value);
236219
if (problem) {

packages/core/test/prompts/prompt.test.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('Prompt', () => {
3838
const resultPromise = instance.prompt();
3939
input.emit('keypress', '', { name: 'return' });
4040
const result = await resultPromise;
41-
expect(result).to.equal('');
41+
expect(result).to.equal(undefined);
4242
expect(isCancel(result)).to.equal(false);
4343
expect(instance.state).to.equal('submit');
4444
expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]);
@@ -136,37 +136,6 @@ describe('Prompt', () => {
136136
expect(eventFn).toBeCalledWith(false);
137137
});
138138

139-
test('sets value as placeholder on tab if one is set', () => {
140-
const instance = new Prompt({
141-
input,
142-
output,
143-
render: () => 'foo',
144-
placeholder: 'piwa',
145-
});
146-
147-
instance.prompt();
148-
149-
input.emit('keypress', '\t', { name: 'tab' });
150-
151-
expect(instance.value).to.equal('piwa');
152-
});
153-
154-
test('does not set placeholder value on tab if value already set', () => {
155-
const instance = new Prompt({
156-
input,
157-
output,
158-
render: () => 'foo',
159-
placeholder: 'piwa',
160-
initialValue: 'trzy',
161-
});
162-
163-
instance.prompt();
164-
165-
input.emit('keypress', '\t', { name: 'tab' });
166-
167-
expect(instance.value).to.equal('trzy');
168-
});
169-
170139
test('emits key event for unknown chars', () => {
171140
const eventSpy = vi.fn();
172141
const instance = new Prompt({

packages/prompts/src/autocomplete.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ export interface AutocompleteOptions<Value> extends CommonOptions {
6767
export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
6868
const prompt = new AutocompletePrompt({
6969
options: opts.options,
70-
placeholder: opts.placeholder,
7170
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
7271
filter: (search: string, opt: Option<Value>) => {
7372
return getFilteredOption(search, opt);
@@ -78,6 +77,8 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
7877
// Title and message display
7978
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
8079
const valueAsString = String(this.value ?? '');
80+
const placeholder = opts.placeholder;
81+
const showPlaceholder = valueAsString === '' && placeholder !== undefined;
8182

8283
// Handle different states
8384
switch (this.state) {
@@ -94,7 +95,10 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
9495

9596
default: {
9697
// Display cursor position - show plain text in navigation mode
97-
const searchText = this.isNavigating ? color.dim(valueAsString) : this.valueWithCursor;
98+
const searchText =
99+
this.isNavigating || showPlaceholder
100+
? color.dim(showPlaceholder ? placeholder : valueAsString)
101+
: this.valueWithCursor;
98102

99103
// Show match count if filtered
100104
const matches =
@@ -220,7 +224,6 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
220224
filter: (search, opt) => {
221225
return getFilteredOption(search, opt);
222226
},
223-
placeholder: opts.placeholder,
224227
initialValue: opts.initialValues,
225228
input: opts.input,
226229
output: opts.output,
@@ -229,16 +232,15 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
229232
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
230233

231234
// Selection counter
232-
const counter =
233-
this.selectedValues.length > 0
234-
? color.cyan(` (${this.selectedValues.length} selected)`)
235-
: '';
236235
const value = String(this.value ?? '');
236+
const placeholder = opts.placeholder;
237+
const showPlaceholder = value === '' && placeholder !== undefined;
237238

238239
// Search input display
239-
const searchText = this.isNavigating
240-
? color.dim(value) // Just show plain text when in navigation mode
241-
: this.valueWithCursor;
240+
const searchText =
241+
this.isNavigating || showPlaceholder
242+
? color.dim(showPlaceholder ? placeholder : value) // Just show plain text when in navigation mode
243+
: this.valueWithCursor;
242244

243245
const matches =
244246
this.filteredOptions.length !== opts.options.length

packages/prompts/src/text.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const text = (opts: TextOptions) => {
3131
S_BAR_END
3232
)} ${color.yellow(this.error)}\n`;
3333
case 'submit': {
34-
const displayValue = typeof this.value === 'undefined' ? '' : this.value;
34+
const displayValue = this.value === undefined ? '' : this.value;
3535
return `${title}${color.gray(S_BAR)} ${color.dim(displayValue)}`;
3636
}
3737
case 'cancel':

packages/prompts/test/__snapshots__/autocomplete.test.ts.snap

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@ exports[`autocomplete > renders initial UI with message and instructions 1`] = `
3535
]
3636
`;
3737
38+
exports[`autocomplete > renders placeholder if set 1`] = `
39+
[
40+
"<cursor.hide>",
41+
"│
42+
◆ Select a fruit
43+
44+
│ Search: Type to search...
45+
│ ● Apple
46+
│ ○ Banana
47+
│ ○ Cherry
48+
│ ○ Grape
49+
│ ○ Orange
50+
│ ↑/↓ to select • Enter: confirm • Type: to search
51+
└",
52+
"<cursor.backward count=999><cursor.up count=10>",
53+
"<cursor.down count=1>",
54+
"<erase.down>",
55+
"◇ Select a fruit
56+
│",
57+
"
58+
",
59+
"<cursor.show>",
60+
]
61+
`;
62+
3863
exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
3964
[
4065
"<cursor.hide>",

packages/prompts/test/autocomplete.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,20 @@ describe('autocomplete', () => {
120120
expect(typeof value === 'symbol').toBe(true);
121121
expect(output.buffer).toMatchSnapshot();
122122
});
123+
124+
test('renders placeholder if set', async () => {
125+
const result = autocomplete({
126+
message: 'Select a fruit',
127+
placeholder: 'Type to search...',
128+
options: testOptions,
129+
input,
130+
output,
131+
});
132+
133+
input.emit('keypress', '', { name: 'return' });
134+
const value = await result;
135+
136+
expect(output.buffer).toMatchSnapshot();
137+
expect(value).toBe(undefined);
138+
});
123139
});

packages/prompts/test/text.test.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,6 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
5555
expect(value).toBe('');
5656
});
5757

58-
test('<tab> applies placeholder', async () => {
59-
const result = prompts.text({
60-
message: 'foo',
61-
placeholder: 'bar',
62-
input,
63-
output,
64-
});
65-
66-
input.emit('keypress', '\t', { name: 'tab' });
67-
input.emit('keypress', '', { name: 'return' });
68-
69-
const value = await result;
70-
71-
expect(value).toBe('bar');
72-
});
73-
7458
test('can cancel', async () => {
7559
const result = prompts.text({
7660
message: 'foo',

0 commit comments

Comments
 (0)