Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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