Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 415410b

Browse files
authored
feat(prompts): add custom filter option to autocomplete (#444)
1 parent 5c65e38 commit 415410b

File tree

4 files changed

+194
-4
lines changed

4 files changed

+194
-4
lines changed

.changeset/afraid-rabbits-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": minor
3+
---
4+
5+
This adds a custom filter function to autocompleteMultiselect. It could be used, for example, to support fuzzy searching logic.

packages/prompts/src/autocomplete.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ interface AutocompleteSharedOptions<Value> extends CommonOptions {
6262
* Validates the value
6363
*/
6464
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
65+
/**
66+
* Custom filter function to match options against search input.
67+
* If not provided, a default filter that matches label, hint, and value is used.
68+
*/
69+
filter?: (search: string, option: Option<Value>) => boolean;
6570
}
6671

6772
export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value> {
@@ -80,9 +85,9 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
8085
options: opts.options,
8186
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
8287
initialUserInput: opts.initialUserInput,
83-
filter: (search: string, opt: Option<Value>) => {
88+
filter: opts.filter ?? ((search: string, opt: Option<Value>) => {
8489
return getFilteredOption(search, opt);
85-
},
90+
}),
8691
signal: opts.signal,
8792
input: opts.input,
8893
output: opts.output,
@@ -238,9 +243,9 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
238243
const prompt = new AutocompletePrompt<Option<Value>>({
239244
options: opts.options,
240245
multiple: true,
241-
filter: (search, opt) => {
246+
filter: opts.filter ?? ((search, opt) => {
242247
return getFilteredOption(search, opt);
243-
},
248+
}),
244249
validate: () => {
245250
if (opts.required && prompt.selectedValues.length === 0) {
246251
return 'Please select at least one item';

packages/prompts/test/__snapshots__/autocomplete.test.ts.snap

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,67 @@ exports[`autocomplete > supports initialValue 1`] = `
335335
]
336336
`;
337337
338+
exports[`autocomplete with custom filter > falls back to default filter when not provided 1`] = `
339+
[
340+
"<cursor.hide>",
341+
"│
342+
◆ Select a fruit
343+
│
344+
│ Search: _
345+
│ ● Apple
346+
│ ○ Banana
347+
│ ○ Cherry
348+
│ ↑/↓ to select • Enter: confirm • Type: to search
349+
└",
350+
"<cursor.backward count=999><cursor.up count=8>",
351+
"<cursor.down count=3>",
352+
"<erase.down>",
353+
"│ Search: a█ (2 matches)
354+
│ ● Apple
355+
│ ○ Banana
356+
│ ↑/↓ to select • Enter: confirm • Type: to search
357+
└",
358+
"<cursor.backward count=999><cursor.up count=7>",
359+
"<cursor.down count=1>",
360+
"<erase.down>",
361+
"◇ Select a fruit
362+
│ Apple",
363+
"
364+
",
365+
"<cursor.show>",
366+
]
367+
`;
368+
369+
exports[`autocomplete with custom filter > uses custom filter function when provided 1`] = `
370+
[
371+
"<cursor.hide>",
372+
"│
373+
◆ Select a fruit
374+
│
375+
│ Search: _
376+
│ ● Apple
377+
│ ○ Banana
378+
│ ○ Cherry
379+
│ ↑/↓ to select • Enter: confirm • Type: to search
380+
└",
381+
"<cursor.backward count=999><cursor.up count=8>",
382+
"<cursor.down count=3>",
383+
"<erase.down>",
384+
"│ Search: a█ (1 match)
385+
│ ● Apple
386+
│ ↑/↓ to select • Enter: confirm • Type: to search
387+
└",
388+
"<cursor.backward count=999><cursor.up count=6>",
389+
"<cursor.down count=1>",
390+
"<erase.down>",
391+
"◇ Select a fruit
392+
│ Apple",
393+
"
394+
",
395+
"<cursor.show>",
396+
]
397+
`;
398+
338399
exports[`autocompleteMultiselect > can be aborted by a signal 1`] = `
339400
[
340401
"<cursor.hide>",
@@ -461,3 +522,40 @@ exports[`autocompleteMultiselect > renders error when empty selection & required
461522
"<cursor.show>",
462523
]
463524
`;
525+
526+
exports[`autocompleteMultiselect > supports custom filter function 1`] = `
527+
[
528+
"<cursor.hide>",
529+
"│
530+
◆ Select fruits
531+
│
532+
│ Search: _
533+
│ ◻ Apple
534+
│ ◻ Banana
535+
│ ◻ Cherry
536+
│ ◻ Grape
537+
│ ◻ Orange
538+
│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search
539+
└",
540+
"<cursor.backward count=999><cursor.up count=10>",
541+
"<cursor.down count=3>",
542+
"<erase.down>",
543+
"│ Search: a█ (1 match)
544+
│ ◻ Apple
545+
│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search
546+
└",
547+
"<cursor.backward count=999><cursor.up count=6>",
548+
"<cursor.down count=4>",
549+
"<erase.line><cursor.left count=1>",
550+
"│ ◼ Apple",
551+
"<cursor.down count=2>",
552+
"<cursor.backward count=999><cursor.up count=6>",
553+
"<cursor.down count=1>",
554+
"<erase.down>",
555+
"◇ Select fruits
556+
│ 1 items selected",
557+
"
558+
",
559+
"<cursor.show>",
560+
]
561+
`;

packages/prompts/test/autocomplete.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,4 +297,86 @@ describe('autocompleteMultiselect', () => {
297297
expect(value).toEqual(['banana', 'cherry']);
298298
expect(output.buffer).toMatchSnapshot();
299299
});
300+
301+
test('supports custom filter function', async () => {
302+
const result = autocompleteMultiselect({
303+
message: 'Select fruits',
304+
options: testOptions,
305+
input,
306+
output,
307+
// Custom filter that only matches exact prefix
308+
filter: (search, option) => {
309+
const label = option.label ?? String(option.value ?? '');
310+
return label.toLowerCase().startsWith(search.toLowerCase());
311+
},
312+
});
313+
314+
// Type 'a' - should match 'Apple' only (not 'Banana' which contains 'a')
315+
input.emit('keypress', 'a', { name: 'a' });
316+
input.emit('keypress', '', { name: 'tab' });
317+
input.emit('keypress', '', { name: 'return' });
318+
319+
const value = await result;
320+
expect(value).toEqual(['apple']);
321+
expect(output.buffer).toMatchSnapshot();
322+
});
323+
});
324+
325+
describe('autocomplete with custom filter', () => {
326+
let input: MockReadable;
327+
let output: MockWritable;
328+
const testOptions = [
329+
{ value: 'apple', label: 'Apple' },
330+
{ value: 'banana', label: 'Banana' },
331+
{ value: 'cherry', label: 'Cherry' },
332+
];
333+
334+
beforeEach(() => {
335+
input = new MockReadable();
336+
output = new MockWritable();
337+
});
338+
339+
afterEach(() => {
340+
vi.restoreAllMocks();
341+
});
342+
343+
test('uses custom filter function when provided', async () => {
344+
const result = autocomplete({
345+
message: 'Select a fruit',
346+
options: testOptions,
347+
input,
348+
output,
349+
// Custom filter that only matches exact prefix
350+
filter: (search, option) => {
351+
const label = option.label ?? String(option.value ?? '');
352+
return label.toLowerCase().startsWith(search.toLowerCase());
353+
},
354+
});
355+
356+
// Type 'a' - should match 'Apple' only (not 'Banana' which contains 'a')
357+
input.emit('keypress', 'a', { name: 'a' });
358+
input.emit('keypress', '', { name: 'return' });
359+
360+
const value = await result;
361+
expect(value).toBe('apple');
362+
expect(output.buffer).toMatchSnapshot();
363+
});
364+
365+
test('falls back to default filter when not provided', async () => {
366+
const result = autocomplete({
367+
message: 'Select a fruit',
368+
options: testOptions,
369+
input,
370+
output,
371+
});
372+
373+
// Type 'a' - default filter should match both 'Apple' and 'Banana'
374+
input.emit('keypress', 'a', { name: 'a' });
375+
input.emit('keypress', '', { name: 'return' });
376+
377+
const value = await result;
378+
// First match should be selected
379+
expect(value).toBe('apple');
380+
expect(output.buffer).toMatchSnapshot();
381+
});
300382
});

0 commit comments

Comments
 (0)