From b08ed03fe9bd3a19abcdb7cffe01fdbdc7ed5570 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:38:47 -0500 Subject: [PATCH] feat(prompts,core): make autocomplete placeholder tabbable --- .changeset/dirty-actors-find.md | 7 +++++ packages/core/src/prompts/autocomplete.ts | 20 +++++++++++++ .../core/test/prompts/autocomplete.test.ts | 18 +++++++++++ packages/prompts/src/autocomplete.ts | 2 ++ packages/prompts/test/autocomplete.test.ts | 30 +++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 .changeset/dirty-actors-find.md diff --git a/.changeset/dirty-actors-find.md b/.changeset/dirty-actors-find.md new file mode 100644 index 00000000..ef4a57fb --- /dev/null +++ b/.changeset/dirty-actors-find.md @@ -0,0 +1,7 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Make the autocomplete placeholder tabbable: when the input is empty and a placeholder is set, pressing Tab fills the input with the placeholder so that options are filtered and the user can confirm with Enter. + diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 0b16df7b..8c6bb782 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -51,6 +51,11 @@ export interface AutocompleteOptions options: T[] | ((this: AutocompletePrompt) => T[]); filter?: FilterFunction; multiple?: boolean; + /** + * When set (non-empty), pressing Tab with no input fills the field with this value + * and runs the normal filter/selection logic so the user can confirm with Enter. + */ + placeholder?: string; } export default class AutocompletePrompt extends Prompt< @@ -66,6 +71,7 @@ export default class AutocompletePrompt extends Prompt< #lastUserInput = ''; #filterFn: FilterFunction; #options: T[] | (() => T[]); + #placeholder: string | undefined; get cursor(): number { return this.#cursor; @@ -94,6 +100,7 @@ export default class AutocompletePrompt extends Prompt< super(opts); this.#options = opts.options; + this.#placeholder = opts.placeholder; const options = this.options; this.filteredOptions = [...options]; this.multiple = opts.multiple === true; @@ -143,6 +150,19 @@ export default class AutocompletePrompt extends Prompt< const isDownKey = key.name === 'down'; const isReturnKey = key.name === 'return'; + // Tab with empty input and placeholder: fill input with placeholder to trigger autocomplete + const isEmptyOrOnlyTab = this.userInput === '' || this.userInput === '\t'; + const hasTabbablePlaceholder = + this.#placeholder !== undefined && this.#placeholder !== ''; + if (key.name === 'tab' && isEmptyOrOnlyTab && hasTabbablePlaceholder) { + if (this.userInput === '\t') { + this._clearUserInput(); + } + this._setUserInput(this.#placeholder, true); + this.isNavigating = false; + return; + } + // Start navigation mode with up/down arrows if (isUpKey || isDownKey) { this.#cursor = findCursor(this.#cursor, isUpKey ? -1 : 1, this.filteredOptions); diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts index f51ed05b..d95ba599 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -197,4 +197,22 @@ describe('AutocompletePrompt', () => { expect(instance.selectedValues).to.deep.equal([]); expect(result).to.deep.equal([]); }); + + test('Tab with empty input and placeholder fills input and submit returns matching option', async () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + placeholder: 'apple', + }); + + const promise = instance.prompt(); + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + const result = await promise; + + expect(instance.userInput).to.equal('apple'); + expect(result).to.equal('apple'); + }); }); diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 0b4bb8b2..bb4dc69f 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -85,6 +85,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { options: opts.options, initialValue: opts.initialValue ? [opts.initialValue] : undefined, initialUserInput: opts.initialUserInput, + placeholder: opts.placeholder, filter: opts.filter ?? ((search: string, opt: Option) => { @@ -267,6 +268,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const prompt = new AutocompletePrompt>({ options: opts.options, multiple: true, + placeholder: opts.placeholder, filter: opts.filter ?? ((search, opt) => { diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index bc60a556..2c1a0adb 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -137,6 +137,21 @@ describe('autocomplete', () => { expect(value).toBe('apple'); }); + test('Tab with placeholder fills input and Enter submits matching option', async () => { + const result = autocomplete({ + message: 'Select a fruit', + placeholder: 'apple', + options: testOptions, + input, + output, + }); + + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + const value = await result; + expect(value).toBe('apple'); + }); + test('supports initialValue', async () => { const result = autocomplete({ message: 'Select a fruit', @@ -410,6 +425,21 @@ describe('autocompleteMultiselect', () => { expect(value).toEqual([]); expect(output.buffer).toMatchSnapshot(); }); + + test('Tab with placeholder fills input; Enter submits current selection', async () => { + const result = autocompleteMultiselect({ + message: 'Select fruits', + placeholder: 'apple', + options: testOptions, + input, + output, + }); + + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + const value = await result; + expect(value).toEqual([]); + }); }); describe('autocomplete with custom filter', () => {