Skip to content

Commit bdf89a5

Browse files
feat(prompts,core): make autocomplete placeholder tabbable (#485)
Co-authored-by: Nate Moore <[email protected]>
1 parent 52fce8a commit bdf89a5

5 files changed

Lines changed: 116 additions & 0 deletions

File tree

.changeset/dirty-actors-find.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@clack/prompts": patch
3+
"@clack/core": patch
4+
---
5+
6+
Adds `placeholder` option to `autocomplete`. When the placeholder is set and the input is empty, pressing `tab` will set the value to `placeholder`.
7+

packages/core/src/prompts/autocomplete.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ export interface AutocompleteOptions<T extends OptionLike>
5151
options: T[] | ((this: AutocompletePrompt<T>) => T[]);
5252
filter?: FilterFunction<T>;
5353
multiple?: boolean;
54+
/**
55+
* When set (non-empty), pressing Tab with no input fills the field with this value
56+
* and runs the normal filter/selection logic so the user can confirm with Enter.
57+
* Tab only fills the input when the placeholder matches at least one option under
58+
* the prompt's filter (so the value remains selectable).
59+
*/
60+
placeholder?: string;
5461
}
5562

5663
export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
@@ -66,6 +73,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
6673
#lastUserInput = '';
6774
#filterFn: FilterFunction<T>;
6875
#options: T[] | (() => T[]);
76+
#placeholder: string | undefined;
6977

7078
get cursor(): number {
7179
return this.#cursor;
@@ -94,6 +102,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
94102
super(opts);
95103

96104
this.#options = opts.options;
105+
this.#placeholder = opts.placeholder;
97106
const options = this.options;
98107
this.filteredOptions = [...options];
99108
this.multiple = opts.multiple === true;
@@ -143,6 +152,24 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
143152
const isDownKey = key.name === 'down';
144153
const isReturnKey = key.name === 'return';
145154

155+
// Tab with empty input and placeholder: fill input with placeholder to trigger autocomplete
156+
// Only when the placeholder matches at least one (non-disabled) option so the value remains selectable
157+
const isEmptyOrOnlyTab = this.userInput === '' || this.userInput === '\t';
158+
const placeholder = this.#placeholder;
159+
const options = this.options;
160+
const placeholderMatchesOption =
161+
placeholder !== undefined &&
162+
placeholder !== '' &&
163+
options.some((opt) => !opt.disabled && this.#filterFn(placeholder, opt));
164+
if (key.name === 'tab' && isEmptyOrOnlyTab && placeholderMatchesOption) {
165+
if (this.userInput === '\t') {
166+
this._clearUserInput();
167+
}
168+
this._setUserInput(placeholder, true);
169+
this.isNavigating = false;
170+
return;
171+
}
172+
146173
// Start navigation mode with up/down arrows
147174
if (isUpKey || isDownKey) {
148175
this.#cursor = findCursor(this.#cursor, isUpKey ? -1 : 1, this.filteredOptions);

packages/core/test/prompts/autocomplete.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,38 @@ describe('AutocompletePrompt', () => {
197197
expect(instance.selectedValues).to.deep.equal([]);
198198
expect(result).to.deep.equal([]);
199199
});
200+
201+
test('Tab with empty input and placeholder fills input and submit returns matching option', async () => {
202+
const instance = new AutocompletePrompt({
203+
input,
204+
output,
205+
render: () => 'foo',
206+
options: testOptions,
207+
placeholder: 'apple',
208+
});
209+
210+
const promise = instance.prompt();
211+
input.emit('keypress', '\t', { name: 'tab' });
212+
input.emit('keypress', '', { name: 'return' });
213+
const result = await promise;
214+
215+
expect(instance.userInput).to.equal('apple');
216+
expect(result).to.equal('apple');
217+
});
218+
219+
test('Tab with non-matching placeholder does not fill input', async () => {
220+
const instance = new AutocompletePrompt({
221+
input,
222+
output,
223+
render: () => 'foo',
224+
options: testOptions,
225+
placeholder: 'Type to search...',
226+
});
227+
228+
instance.prompt();
229+
input.emit('keypress', '\t', { name: 'tab' });
230+
231+
// Placeholder does not match any option, so input must not be filled with placeholder
232+
expect(instance.userInput).not.to.equal('Type to search...');
233+
});
200234
});

packages/prompts/src/autocomplete.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
8585
options: opts.options,
8686
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
8787
initialUserInput: opts.initialUserInput,
88+
placeholder: opts.placeholder,
8889
filter:
8990
opts.filter ??
9091
((search: string, opt: Option<Value>) => {
@@ -267,6 +268,7 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
267268
const prompt = new AutocompletePrompt<Option<Value>>({
268269
options: opts.options,
269270
multiple: true,
271+
placeholder: opts.placeholder,
270272
filter:
271273
opts.filter ??
272274
((search, opt) => {

packages/prompts/test/autocomplete.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,37 @@ describe('autocomplete', () => {
137137
expect(value).toBe('apple');
138138
});
139139

140+
test('Tab with placeholder fills input and Enter submits matching option', async () => {
141+
const result = autocomplete({
142+
message: 'Select a fruit',
143+
placeholder: 'apple',
144+
options: testOptions,
145+
input,
146+
output,
147+
});
148+
149+
input.emit('keypress', '\t', { name: 'tab' });
150+
input.emit('keypress', '', { name: 'return' });
151+
const value = await result;
152+
expect(value).toBe('apple');
153+
});
154+
155+
test('Tab with non-matching placeholder does not fill input', async () => {
156+
const result = autocomplete({
157+
message: 'Select a fruit',
158+
placeholder: 'Type to search...',
159+
options: testOptions,
160+
input,
161+
output,
162+
});
163+
164+
input.emit('keypress', '\t', { name: 'tab' });
165+
input.emit('keypress', '', { name: 'return' });
166+
const value = await result;
167+
// Tab did not fill input with placeholder (no option matches), so Enter submits first option
168+
expect(value).toBe('apple');
169+
});
170+
140171
test('supports initialValue', async () => {
141172
const result = autocomplete({
142173
message: 'Select a fruit',
@@ -410,6 +441,21 @@ describe('autocompleteMultiselect', () => {
410441
expect(value).toEqual([]);
411442
expect(output.buffer).toMatchSnapshot();
412443
});
444+
445+
test('Tab with placeholder fills input; Enter submits current selection', async () => {
446+
const result = autocompleteMultiselect({
447+
message: 'Select fruits',
448+
placeholder: 'apple',
449+
options: testOptions,
450+
input,
451+
output,
452+
});
453+
454+
input.emit('keypress', '\t', { name: 'tab' });
455+
input.emit('keypress', '', { name: 'return' });
456+
const value = await result;
457+
expect(value).toEqual([]);
458+
});
413459
});
414460

415461
describe('autocomplete with custom filter', () => {

0 commit comments

Comments
 (0)