diff --git a/.changeset/fix-autocomplete-double-filter.md b/.changeset/fix-autocomplete-double-filter.md new file mode 100644 index 00000000..68dfd7df --- /dev/null +++ b/.changeset/fix-autocomplete-double-filter.md @@ -0,0 +1,7 @@ +--- +'@clack/core': minor +--- + +feat(core): skip default filter when options is a function + +When `options` is provided as a function, the default filter is no longer applied on top of the function's results. If both `options` (function) and a custom `filter` are provided, the filter is still applied. diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 0b16df7b..301158a2 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -65,6 +65,7 @@ export default class AutocompletePrompt extends Prompt< #cursor = 0; #lastUserInput = ''; #filterFn: FilterFunction; + #hasCustomFilter: boolean; #options: T[] | (() => T[]); get cursor(): number { @@ -97,6 +98,7 @@ export default class AutocompletePrompt extends Prompt< const options = this.options; this.filteredOptions = [...options]; this.multiple = opts.multiple === true; + this.#hasCustomFilter = opts.filter !== undefined; this.#filterFn = opts.filter ?? defaultFilter; let initialValues: unknown[] | undefined; if (opts.initialValue && Array.isArray(opts.initialValue)) { @@ -197,9 +199,16 @@ export default class AutocompletePrompt extends Prompt< this.#lastUserInput = value; const options = this.options; + const isOptionsFunction = typeof this.#options === 'function'; if (value) { - this.filteredOptions = options.filter((opt) => this.#filterFn(value, opt)); + // When options is a function and no custom filter was provided, + // skip filtering since the function already returns filtered results. + if (isOptionsFunction && !this.#hasCustomFilter) { + this.filteredOptions = [...options]; + } else { + this.filteredOptions = options.filter((opt) => this.#filterFn(value, opt)); + } } else { this.filteredOptions = [...options]; } diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts index f51ed05b..00266fbe 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -197,4 +197,59 @@ describe('AutocompletePrompt', () => { expect(instance.selectedValues).to.deep.equal([]); expect(result).to.deep.equal([]); }); + + test('options function skips default filter when no custom filter provided', () => { + const optionsFn = function (this: any) { + const input = this.userInput || ''; + if (!input) return testOptions; + return testOptions.filter((opt) => opt.value.startsWith(input)); + }; + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: optionsFn, + }); + + instance.prompt(); + + // Type 'b' - options function returns only 'banana' + input.emit('keypress', 'b', { name: 'b' }); + + // Should use the options function result as-is, no additional filtering + expect(instance.filteredOptions).toEqual([ + { value: 'banana', label: 'Banana' }, + ]); + }); + + test('options function applies custom filter when both are provided', () => { + const optionsFn = function (this: any) { + return testOptions; + }; + + const customFilter = (search: string, opt: { value: string; label?: string }) => { + return opt.value.includes(search); + }; + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: optionsFn, + filter: customFilter, + }); + + instance.prompt(); + + // Type 'an' - custom filter should run on top of options function result + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'n', { name: 'n' }); + + // Custom filter: 'an' matches banana and orange + expect(instance.filteredOptions).toEqual([ + { value: 'banana', label: 'Banana' }, + { value: 'orange', label: 'Orange' }, + ]); + }); });