Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/afraid-rabbits-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": minor
---

This adds a custom filter function to autocompleteMultiselect. It could be used, for example, to support fuzzy searching logic.
13 changes: 9 additions & 4 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ interface AutocompleteSharedOptions<Value> extends CommonOptions {
* Validates the value
*/
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
/**
* Custom filter function to match options against search input.
* If not provided, a default filter that matches label, hint, and value is used.
*/
filter?: (search: string, option: Option<Value>) => boolean;
}

export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value> {
Expand All @@ -80,9 +85,9 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
options: opts.options,
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
initialUserInput: opts.initialUserInput,
filter: (search: string, opt: Option<Value>) => {
filter: opts.filter ?? ((search: string, opt: Option<Value>) => {
return getFilteredOption(search, opt);
},
}),
signal: opts.signal,
input: opts.input,
output: opts.output,
Expand Down Expand Up @@ -238,9 +243,9 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
const prompt = new AutocompletePrompt<Option<Value>>({
options: opts.options,
multiple: true,
filter: (search, opt) => {
filter: opts.filter ?? ((search, opt) => {
return getFilteredOption(search, opt);
},
}),
validate: () => {
if (opts.required && prompt.selectedValues.length === 0) {
return 'Please select at least one item';
Expand Down
98 changes: 98 additions & 0 deletions packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,67 @@ exports[`autocomplete > supports initialValue 1`] = `
]
`;

exports[`autocomplete with custom filter > falls back to default filter when not provided 1`] = `
[
"<cursor.hide>",
"│
◆ Select a fruit
│
│ Search: _
│ ● Apple
│ ○ Banana
│ ○ Cherry
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=8>",
"<cursor.down count=3>",
"<erase.down>",
"│ Search: a█ (2 matches)
│ ● Apple
│ ○ Banana
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=7>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select a fruit
│ Apple",
"
",
"<cursor.show>",
]
`;

exports[`autocomplete with custom filter > uses custom filter function when provided 1`] = `
[
"<cursor.hide>",
"│
◆ Select a fruit
│
│ Search: _
│ ● Apple
│ ○ Banana
│ ○ Cherry
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=8>",
"<cursor.down count=3>",
"<erase.down>",
"│ Search: a█ (1 match)
│ ● Apple
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=6>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select a fruit
│ Apple",
"
",
"<cursor.show>",
]
`;

exports[`autocompleteMultiselect > can be aborted by a signal 1`] = `
[
"<cursor.hide>",
Expand Down Expand Up @@ -461,3 +522,40 @@ exports[`autocompleteMultiselect > renders error when empty selection & required
"<cursor.show>",
]
`;

exports[`autocompleteMultiselect > supports custom filter function 1`] = `
[
"<cursor.hide>",
"│
◆ Select fruits
│
│ Search: _
│ ◻ Apple
│ ◻ Banana
│ ◻ Cherry
│ ◻ Grape
│ ◻ Orange
│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=10>",
"<cursor.down count=3>",
"<erase.down>",
"│ Search: a█ (1 match)
│ ◻ Apple
│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=6>",
"<cursor.down count=4>",
"<erase.line><cursor.left count=1>",
"│ ◼ Apple",
"<cursor.down count=2>",
"<cursor.backward count=999><cursor.up count=6>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select fruits
│ 1 items selected",
"
",
"<cursor.show>",
]
`;
82 changes: 82 additions & 0 deletions packages/prompts/test/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,86 @@ describe('autocompleteMultiselect', () => {
expect(value).toEqual(['banana', 'cherry']);
expect(output.buffer).toMatchSnapshot();
});

test('supports custom filter function', async () => {
const result = autocompleteMultiselect({
message: 'Select fruits',
options: testOptions,
input,
output,
// Custom filter that only matches exact prefix
filter: (search, option) => {
const label = option.label ?? String(option.value ?? '');
return label.toLowerCase().startsWith(search.toLowerCase());
},
});

// Type 'a' - should match 'Apple' only (not 'Banana' which contains 'a')
input.emit('keypress', 'a', { name: 'a' });
input.emit('keypress', '', { name: 'tab' });
input.emit('keypress', '', { name: 'return' });

const value = await result;
expect(value).toEqual(['apple']);
expect(output.buffer).toMatchSnapshot();
});
});

describe('autocomplete with custom filter', () => {
let input: MockReadable;
let output: MockWritable;
const testOptions = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
];

beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});

afterEach(() => {
vi.restoreAllMocks();
});

test('uses custom filter function when provided', async () => {
const result = autocomplete({
message: 'Select a fruit',
options: testOptions,
input,
output,
// Custom filter that only matches exact prefix
filter: (search, option) => {
const label = option.label ?? String(option.value ?? '');
return label.toLowerCase().startsWith(search.toLowerCase());
},
});

// Type 'a' - should match 'Apple' only (not 'Banana' which contains 'a')
input.emit('keypress', 'a', { name: 'a' });
input.emit('keypress', '', { name: 'return' });

const value = await result;
expect(value).toBe('apple');
expect(output.buffer).toMatchSnapshot();
});

test('falls back to default filter when not provided', async () => {
const result = autocomplete({
message: 'Select a fruit',
options: testOptions,
input,
output,
});

// Type 'a' - default filter should match both 'Apple' and 'Banana'
input.emit('keypress', 'a', { name: 'a' });
input.emit('keypress', '', { name: 'return' });

const value = await result;
// First match should be selected
expect(value).toBe('apple');
expect(output.buffer).toMatchSnapshot();
});
});
Loading