Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dirty-actors-find.md
Original file line number Diff line number Diff line change
@@ -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.

20 changes: 20 additions & 0 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface AutocompleteOptions<T extends OptionLike>
options: T[] | ((this: AutocompletePrompt<T>) => T[]);
filter?: FilterFunction<T>;
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<T extends OptionLike> extends Prompt<
Expand All @@ -66,6 +71,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
#lastUserInput = '';
#filterFn: FilterFunction<T>;
#options: T[] | (() => T[]);
#placeholder: string | undefined;

get cursor(): number {
return this.#cursor;
Expand Down Expand Up @@ -94,6 +100,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
super(opts);

this.#options = opts.options;
this.#placeholder = opts.placeholder;
const options = this.options;
this.filteredOptions = [...options];
this.multiple = opts.multiple === true;
Expand Down Expand Up @@ -143,6 +150,19 @@ export default class AutocompletePrompt<T extends OptionLike> 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);
Expand Down
18 changes: 18 additions & 0 deletions packages/core/test/prompts/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
2 changes: 2 additions & 0 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
options: opts.options,
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
initialUserInput: opts.initialUserInput,
placeholder: opts.placeholder,
filter:
opts.filter ??
((search: string, opt: Option<Value>) => {
Expand Down Expand Up @@ -267,6 +268,7 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
const prompt = new AutocompletePrompt<Option<Value>>({
options: opts.options,
multiple: true,
placeholder: opts.placeholder,
filter:
opts.filter ??
((search, opt) => {
Expand Down
30 changes: 30 additions & 0 deletions packages/prompts/test/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down