From 489a49396a80d83eaa587a024b0182848a26ad5e Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:21:37 +0000 Subject: [PATCH] fix: support disabled options in autocomplete Fixes #464. --- .changeset/bitter-sides-accept.md | 6 + packages/core/src/prompts/autocomplete.ts | 10 +- packages/core/src/utils/cursor.ts | 4 + packages/prompts/src/autocomplete.ts | 29 ++- .../__snapshots__/autocomplete.test.ts.snap | 223 ++++++++++++++++++ packages/prompts/test/autocomplete.test.ts | 74 ++++++ 6 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 .changeset/bitter-sides-accept.md diff --git a/.changeset/bitter-sides-accept.md b/.changeset/bitter-sides-accept.md new file mode 100644 index 00000000..63e99b42 --- /dev/null +++ b/.changeset/bitter-sides-accept.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Disallow selection of disabled options in autocomplete. diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 6eb7973b..2602641b 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -203,8 +203,14 @@ export default class AutocompletePrompt extends Prompt< } else { this.filteredOptions = [...options]; } - this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions); - this.focusedValue = this.filteredOptions[this.#cursor]?.value; + const valueCursor = getCursorForValue(this.focusedValue, this.filteredOptions); + this.#cursor = findCursor(valueCursor, 0, this.filteredOptions); + const focusedOption = this.filteredOptions[this.#cursor]; + if (focusedOption && !focusedOption.disabled) { + this.focusedValue = focusedOption.value; + } else { + this.focusedValue = undefined; + } if (!this.multiple) { if (this.focusedValue !== undefined) { this.toggleSelected(this.focusedValue); diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts index 1e935aa8..865067dd 100644 --- a/packages/core/src/utils/cursor.ts +++ b/packages/core/src/utils/cursor.ts @@ -3,6 +3,10 @@ export function findCursor( delta: number, options: T[] ) { + const hasEnabledOptions = options.some((opt) => !opt.disabled); + if (!hasEnabledOptions) { + return cursor; + } const newCursor = cursor + delta; const maxCursor = Math.max(options.length - 1, 0); const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor; diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 13c5b004..80a01d1d 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -104,6 +104,19 @@ export const autocomplete = (opts: AutocompleteOptions) => { const options = this.options; const placeholder = opts.placeholder; const showPlaceholder = userInput === '' && placeholder !== undefined; + const opt = (option: Option, state: 'inactive' | 'active' | 'disabled') => { + const label = getLabel(option); + const hint = + option.hint && option.value === this.focusedValue ? color.dim(` (${option.hint})`) : ''; + switch (state) { + case 'active': + return `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`; + case 'inactive': + return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`; + case 'disabled': + return `${color.gray(S_RADIO_INACTIVE)} ${color.strikethrough(color.gray(label))}`; + } + }; // Handle different states switch (this.state) { @@ -180,15 +193,10 @@ export const autocomplete = (opts: AutocompleteOptions) => { columnPadding: hasGuide ? 3 : 0, // for `| ` when guide is shown rowPadding: headings.length + footers.length, style: (option, active) => { - const label = getLabel(option); - const hint = - option.hint && option.value === this.focusedValue - ? color.dim(` (${option.hint})`) - : ''; - - return active - ? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}` - : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`; + return opt( + option, + option.disabled ? 'disabled' : active ? 'active' : 'inactive' + ); }, maxItems: opts.maxItems, output: opts.output, @@ -239,6 +247,9 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti : ''; const checkbox = isSelected ? color.green(S_CHECKBOX_SELECTED) : color.dim(S_CHECKBOX_INACTIVE); + if (option.disabled) { + return `${color.gray(S_CHECKBOX_INACTIVE)} ${color.strikethrough(color.gray(label))}`; + } if (active) { return `${checkbox} ${label}${hint}`; } diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 4b3b2ed4..1947c22b 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -20,6 +20,115 @@ exports[`autocomplete > can be aborted by a signal 1`] = ` ] `; +exports[`autocomplete > cannot select disabled options when only one left 1`] = ` +[ + "", + "│ +◆ Select a fruit +│ +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: k█ (1 match) +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│", + " +", + "", +] +`; + +exports[`autocomplete > displays disabled options correctly 1`] = ` +[ + "", + "│ +◆ Select a fruit +│ +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: +│ ○ Apple +│ ● Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ○ Banana +│ ● Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ○ Cherry +│ ● Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ○ Grape +│ ● Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ Apple", + " +", + "", +] +`; + exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` [ "", @@ -504,6 +613,120 @@ exports[`autocompleteMultiselect > can use navigation keys to select options 1`] ] `; +exports[`autocompleteMultiselect > cannot select disabled options when only one left 1`] = ` +[ + "", + "│ +◆ Select a fruit +│ +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ◻ Kiwi +│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: k█ (1 match) +│ ◻ Kiwi +│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ 0 items selected", + " +", + "", +] +`; + +exports[`autocompleteMultiselect > displays disabled options correctly 1`] = ` +[ + "", + "│ +◆ Select a fruit +│ +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ◻ Kiwi +│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search:  +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ◻ Kiwi +│ ↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ◻ Kiwi +│ ↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ◻ Kiwi +│ ↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◻ Grape +│ ◻ Orange +│ ◻ Kiwi +│ ↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ◻ Kiwi +│ ↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◼ Apple", + "", + "", + "", + "", + "◇ Select a fruit +│ 1 items selected", + " +", + "", +] +`; + exports[`autocompleteMultiselect > renders error when empty selection & required is true 1`] = ` [ "", diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index fe3842ed..bc60a556 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -241,6 +241,42 @@ describe('autocomplete', () => { expect(output.buffer).toMatchSnapshot(); expect(value).toBe('grape'); }); + + test('displays disabled options correctly', async () => { + const optionsWithDisabled = [...testOptions, { value: 'kiwi', label: 'Kiwi', disabled: true }]; + const result = autocomplete({ + message: 'Select a fruit', + options: optionsWithDisabled, + input, + output, + }); + + for (let i = 0; i < 5; i++) { + input.emit('keypress', '', { name: 'down' }); + } + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + expect(value).toBe('apple'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('cannot select disabled options when only one left', async () => { + const optionsWithDisabled = [...testOptions, { value: 'kiwi', label: 'Kiwi', disabled: true }]; + const result = autocomplete({ + message: 'Select a fruit', + options: optionsWithDisabled, + input, + output, + }); + + input.emit('keypress', 'k', { name: 'k' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + expect(value).toBe(undefined); + expect(output.buffer).toMatchSnapshot(); + }); }); describe('autocompleteMultiselect', () => { @@ -336,6 +372,44 @@ describe('autocompleteMultiselect', () => { expect(value).toEqual(['apple']); expect(output.buffer).toMatchSnapshot(); }); + + test('displays disabled options correctly', async () => { + const optionsWithDisabled = [...testOptions, { value: 'kiwi', label: 'Kiwi', disabled: true }]; + const result = autocompleteMultiselect({ + message: 'Select a fruit', + options: optionsWithDisabled, + input, + output, + }); + + for (let i = 0; i < testOptions.length; i++) { + input.emit('keypress', '', { name: 'down' }); + } + input.emit('keypress', '', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + expect(value).toEqual(['apple']); + expect(output.buffer).toMatchSnapshot(); + }); + + test('cannot select disabled options when only one left', async () => { + const optionsWithDisabled = [...testOptions, { value: 'kiwi', label: 'Kiwi', disabled: true }]; + const result = autocompleteMultiselect({ + message: 'Select a fruit', + options: optionsWithDisabled, + input, + output, + }); + + input.emit('keypress', 'k', { name: 'k' }); + input.emit('keypress', '', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + expect(value).toEqual([]); + expect(output.buffer).toMatchSnapshot(); + }); }); describe('autocomplete with custom filter', () => {