diff --git a/.changeset/select-key-disabled-rendering.md b/.changeset/select-key-disabled-rendering.md new file mode 100644 index 00000000..16cce075 --- /dev/null +++ b/.changeset/select-key-disabled-rendering.md @@ -0,0 +1,6 @@ +--- +"@clack/core": minor +"@clack/prompts": minor +--- + +Add support for disabled `selectKey` options: they are rendered dimmed, cannot be selected by keypress, and are ignored when set as the initial value. diff --git a/packages/core/src/prompts/select-key.ts b/packages/core/src/prompts/select-key.ts index 04a49a52..708d3d4d 100644 --- a/packages/core/src/prompts/select-key.ts +++ b/packages/core/src/prompts/select-key.ts @@ -1,11 +1,13 @@ import Prompt, { type PromptOptions } from './prompt.js'; -export interface SelectKeyOptions +export interface SelectKeyOptions extends PromptOptions> { options: T[]; caseSensitive?: boolean; } -export default class SelectKeyPrompt extends Prompt { +export default class SelectKeyPrompt< + T extends { value: string; disabled?: boolean }, +> extends Prompt { options: T[]; cursor = 0; @@ -17,7 +19,8 @@ export default class SelectKeyPrompt extends Prompt const keys = this.options.map(({ value: [initial] }) => { return caseSensitive ? initial : initial?.toLowerCase(); }); - this.cursor = Math.max(keys.indexOf(opts.initialValue), 0); + const cursor = Math.max(keys.indexOf(opts.initialValue), 0); + this.cursor = this.options[cursor]?.disabled ? 0 : cursor; this.on('key', (key) => { if (!key) { @@ -28,7 +31,10 @@ export default class SelectKeyPrompt extends Prompt return; } - const value = this.options.find(({ value: [initial] }) => { + const value = this.options.find(({ value: [initial], disabled }) => { + if (disabled) { + return false; + } return caseSensitive ? initial === casedKey : initial?.toLowerCase() === casedKey; }); if (value) { diff --git a/packages/prompts/src/select-key.ts b/packages/prompts/src/select-key.ts index 300aece2..979b3d29 100644 --- a/packages/prompts/src/select-key.ts +++ b/packages/prompts/src/select-key.ts @@ -14,12 +14,17 @@ export interface SelectKeyOptions extends CommonOptions { export const selectKey = (opts: SelectKeyOptions) => { const opt = ( option: Option | undefined, - state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive' + state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled' = 'inactive' ) => { if (option === undefined) { return ''; } const label = option.label ?? String(option.value); + if (state === 'disabled') { + return `${styleText('dim', ` ${option.value} `)} ${styleText('gray', label)}${ + option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' + }`; + } if (state === 'selected') { return `${styleText('dim', label)}`; } @@ -75,7 +80,10 @@ export const selectKey = (opts: SelectKeyOptions) = .map((option, i) => wrapTextWithPrefix( opts.output, - opt(option, i === this.cursor ? 'active' : 'inactive'), + opt( + option, + option.disabled ? 'disabled' : i === this.cursor ? 'active' : 'inactive' + ), defaultPrefix ) ) diff --git a/packages/prompts/test/__snapshots__/select-key.test.ts.snap b/packages/prompts/test/__snapshots__/select-key.test.ts.snap index fe08336f..5fd7cb99 100644 --- a/packages/prompts/test/__snapshots__/select-key.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select-key.test.ts.snap @@ -81,6 +81,26 @@ exports[`text (isCI = false) > global withGuide: false removes guide 1`] = ` ] `; +exports[`text (isCI = false) > ignores a disabled initial value 1`] = ` +[ + "", + "│ +◆ foo +│  d  Disabled +│  e  Enabled +└ +", + "", + "", + "", + "", + "◇ foo +│ Enabled", + " +", +] +`; + exports[`text (isCI = false) > input is case-insensitive by default 1`] = ` [ "", @@ -248,6 +268,26 @@ exports[`text (isCI = false) > options are case-insensitive by default 1`] = ` ] `; +exports[`text (isCI = false) > renders disabled options dimmed 1`] = ` +[ + "", + "│ +◆ foo +│  d  Disabled (unavailable) +│  e  Enabled +└ +", + "", + "", + "", + "", + "◇ foo +│ Enabled", + " +", +] +`; + exports[`text (isCI = false) > renders message with options 1`] = ` [ "", @@ -387,6 +427,26 @@ exports[`text (isCI = true) > global withGuide: false removes guide 1`] = ` ] `; +exports[`text (isCI = true) > ignores a disabled initial value 1`] = ` +[ + "", + "│ +◆ foo +│  d  Disabled +│  e  Enabled +└ +", + "", + "", + "", + "", + "◇ foo +│ Enabled", + " +", +] +`; + exports[`text (isCI = true) > input is case-insensitive by default 1`] = ` [ "", @@ -554,6 +614,26 @@ exports[`text (isCI = true) > options are case-insensitive by default 1`] = ` ] `; +exports[`text (isCI = true) > renders disabled options dimmed 1`] = ` +[ + "", + "│ +◆ foo +│  d  Disabled (unavailable) +│  e  Enabled +└ +", + "", + "", + "", + "", + "◇ foo +│ Enabled", + " +", +] +`; + exports[`text (isCI = true) > renders message with options 1`] = ` [ "", diff --git a/packages/prompts/test/select-key.test.ts b/packages/prompts/test/select-key.test.ts index 856afde8..dd5731de 100644 --- a/packages/prompts/test/select-key.test.ts +++ b/packages/prompts/test/select-key.test.ts @@ -65,6 +65,76 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { expect(value).toBe('b'); }); + test('renders disabled options dimmed', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Disabled', value: 'd', disabled: true, hint: 'unavailable' }, + { label: 'Enabled', value: 'e' }, + ], + input, + output, + }); + + input.emit('keypress', 'e', { name: 'e' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('does not select disabled options', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Disabled', value: 'd', disabled: true }, + { label: 'Enabled', value: 'e' }, + ], + input, + output, + }); + + input.emit('keypress', 'd', { name: 'd' }); + input.emit('keypress', 'e', { name: 'e' }); + + expect(await result).toBe('e'); + }); + + test('skips disabled options sharing the same initial', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'delete', value: 'delete', disabled: true }, + { label: 'deploy', value: 'deploy' }, + ], + input, + output, + }); + + input.emit('keypress', 'd', { name: 'd' }); + + expect(await result).toBe('deploy'); + }); + + test('ignores a disabled initial value', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Disabled', value: 'd', disabled: true }, + { label: 'Enabled', value: 'e' }, + ], + initialValue: 'd', + input, + output, + }); + + input.emit('keypress', 'e', { name: 'e' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + test('can cancel by pressing escape', async () => { const result = prompts.selectKey({ message: 'foo',