diff --git a/.changeset/itchy-coins-cry.md b/.changeset/itchy-coins-cry.md new file mode 100644 index 00000000..5e158684 --- /dev/null +++ b/.changeset/itchy-coins-cry.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Allow disabled options in multi-select and select prompts. diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index 2781910e..7f4643ba 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -1,13 +1,19 @@ +import { findCursor } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; -interface MultiSelectOptions +interface OptionLike { + value: any; + disabled?: boolean; +} + +interface MultiSelectOptions extends PromptOptions> { options: T[]; initialValues?: T['value'][]; required?: boolean; cursorAt?: T['value']; } -export default class MultiSelectPrompt extends Prompt { +export default class MultiSelectPrompt extends Prompt { options: T[]; cursor = 0; @@ -15,9 +21,14 @@ export default class MultiSelectPrompt extends Prompt< return this.options[this.cursor].value; } + private get _enabledOptions(): T[] { + return this.options.filter((option) => option.disabled !== true); + } + private toggleAll() { - const allSelected = this.value !== undefined && this.value.length === this.options.length; - this.value = allSelected ? [] : this.options.map((v) => v.value); + const enabledOptions = this._enabledOptions; + const allSelected = this.value !== undefined && this.value.length === enabledOptions.length; + this.value = allSelected ? [] : enabledOptions.map((v) => v.value); } private toggleInvert() { @@ -25,7 +36,7 @@ export default class MultiSelectPrompt extends Prompt< if (!value) { return; } - const notSelected = this.options.filter((v) => !value.includes(v.value)); + const notSelected = this._enabledOptions.filter((v) => !value.includes(v.value)); this.value = notSelected.map((v) => v.value); } @@ -44,10 +55,11 @@ export default class MultiSelectPrompt extends Prompt< this.options = opts.options; this.value = [...(opts.initialValues ?? [])]; - this.cursor = Math.max( + const cursor = Math.max( this.options.findIndex(({ value }) => value === opts.cursorAt), 0 ); + this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; this.on('key', (char) => { if (char === 'a') { this.toggleAll(); @@ -61,11 +73,11 @@ export default class MultiSelectPrompt extends Prompt< switch (key) { case 'left': case 'up': - this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + this.cursor = findCursor(this.cursor, -1, this.options); break; case 'down': case 'right': - this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + this.cursor = findCursor(this.cursor, 1, this.options); break; case 'space': this.toggleValue(); diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index d24928b9..b169e50e 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -1,11 +1,14 @@ +import { findCursor } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; -interface SelectOptions +interface SelectOptions extends PromptOptions> { options: T[]; initialValue?: T['value']; } -export default class SelectPrompt extends Prompt { +export default class SelectPrompt extends Prompt< + T['value'] +> { options: T[]; cursor = 0; @@ -21,19 +24,21 @@ export default class SelectPrompt extends Prompt value === opts.initialValue); - if (this.cursor === -1) this.cursor = 0; + + const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue); + const cursor = initialCursor === -1 ? 0 : initialCursor; + this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; this.changeValue(); this.on('cursor', (key) => { switch (key) { case 'left': case 'up': - this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + this.cursor = findCursor(this.cursor, -1, this.options); break; case 'down': case 'right': - this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + this.cursor = findCursor(this.cursor, 1, this.options); break; } this.changeValue(); diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts new file mode 100644 index 00000000..1e935aa8 --- /dev/null +++ b/packages/core/src/utils/cursor.ts @@ -0,0 +1,14 @@ +export function findCursor( + cursor: number, + delta: number, + options: T[] +) { + const newCursor = cursor + delta; + const maxCursor = Math.max(options.length - 1, 0); + const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor; + const newOption = options[clampedCursor]; + if (newOption.disabled) { + return findCursor(clampedCursor, delta < 0 ? -1 : 1, options); + } + return clampedCursor; +} diff --git a/packages/core/test/prompts/multi-select.test.ts b/packages/core/test/prompts/multi-select.test.ts index b1ee7432..99695793 100644 --- a/packages/core/test/prompts/multi-select.test.ts +++ b/packages/core/test/prompts/multi-select.test.ts @@ -130,5 +130,84 @@ describe('MultiSelectPrompt', () => { input.emit('keypress', 'i', { name: 'i' }); expect(instance.value).toEqual(['foo']); }); + + test('disabled options are skipped', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(2); + input.emit('keypress', 'up', { name: 'up' }); + expect(instance.cursor).to.equal(0); + }); + + test('initial cursorAt on disabled option', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + cursorAt: 'bar', + }); + instance.prompt(); + + expect(instance.cursor).to.equal(2); + }); + }); + + describe('toggleAll', () => { + test('selects all enabled options', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + + input.emit('keypress', 'a', { name: 'a' }); + expect(instance.value).toEqual(['foo', 'baz']); + }); + + test('unselects all enabled options if all selected', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + initialValues: ['foo', 'baz'], + }); + instance.prompt(); + + input.emit('keypress', 'a', { name: 'a' }); + expect(instance.value).toEqual([]); + }); + }); + + describe('toggleInvert', () => { + test('inverts selection of enabled options', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [ + { value: 'foo' }, + { value: 'bar', disabled: true }, + { value: 'baz' }, + { value: 'qux' }, + ], + initialValues: ['foo', 'baz'], + }); + instance.prompt(); + + input.emit('keypress', 'i', { name: 'i' }); + expect(instance.value).toEqual(['qux']); + }); }); }); diff --git a/packages/core/test/prompts/select.test.ts b/packages/core/test/prompts/select.test.ts index 996b4f56..a3583061 100644 --- a/packages/core/test/prompts/select.test.ts +++ b/packages/core/test/prompts/select.test.ts @@ -100,5 +100,43 @@ describe('SelectPrompt', () => { instance.prompt(); expect(instance.cursor).to.equal(1); }); + + test('cursor skips disabled options (down)', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(2); + }); + + test('cursor skips disabled options (up)', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + initialValue: 'baz', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + expect(instance.cursor).to.equal(2); + input.emit('keypress', 'up', { name: 'up' }); + expect(instance.cursor).to.equal(0); + }); + + test('cursor skips initial disabled option', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo', disabled: true }, { value: 'bar' }, { value: 'baz' }], + }); + instance.prompt(); + expect(instance.cursor).to.equal(1); + }); }); }); diff --git a/packages/prompts/README.md b/packages/prompts/README.md index ba474b6a..5cbe88e4 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -86,7 +86,7 @@ const projectType = await select({ message: 'Pick a project type.', options: [ { value: 'ts', label: 'TypeScript' }, - { value: 'js', label: 'JavaScript' }, + { value: 'js', label: 'JavaScript', disabled: true }, { value: 'coffee', label: 'CoffeeScript', hint: 'oh no' }, ], }); @@ -103,7 +103,7 @@ const additionalTools = await multiselect({ message: 'Select additional tools.', options: [ { value: 'eslint', label: 'ESLint', hint: 'recommended' }, - { value: 'prettier', label: 'Prettier' }, + { value: 'prettier', label: 'Prettier', disabled: true }, { value: 'gh-action', label: 'GitHub Action' }, ], required: false, diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 1d928a7e..75ca9045 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -23,9 +23,21 @@ export interface MultiSelectOptions extends CommonOptions { export const multiselect = (opts: MultiSelectOptions) => { const opt = ( option: Option, - state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'submitted' | 'cancelled' + state: + | 'inactive' + | 'active' + | 'selected' + | 'active-selected' + | 'submitted' + | 'cancelled' + | 'disabled' ) => { const label = option.label ?? String(option.value); + if (state === 'disabled') { + return `${color.gray(S_CHECKBOX_INACTIVE)} ${color.gray(label)}${ + option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : '' + }`; + } if (state === 'active') { return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label}${ option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' @@ -74,6 +86,9 @@ export const multiselect = (opts: MultiSelectOptions) => { const value = this.value ?? []; const styleOption = (option: Option, active: boolean) => { + if (option.disabled) { + return opt(option, 'disabled'); + } const selected = value.includes(option.value); if (active && selected) { return opt(option, 'active-selected'); @@ -103,28 +118,32 @@ export const multiselect = (opts: MultiSelectOptions) => { }`; } case 'error': { + const prefix = `${color.yellow(S_BAR)} `; const footer = this.error .split('\n') .map((ln, i) => i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` ) .join('\n'); - return `${title + color.yellow(S_BAR)} ${limitOptions({ + return `${title}${prefix}${limitOptions({ output: opts.output, options: this.options, cursor: this.cursor, maxItems: opts.maxItems, + columnPadding: prefix.length, style: styleOption, - }).join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; + }).join(`\n${prefix}`)}\n${footer}\n`; } default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ + const prefix = `${color.cyan(S_BAR)} `; + return `${title}${prefix}${limitOptions({ output: opts.output, options: this.options, cursor: this.cursor, maxItems: opts.maxItems, + columnPadding: prefix.length, style: styleOption, - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`; } } }, diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index 1dafaea3..b091161c 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -31,6 +31,13 @@ export type Option = Value extends Primitive * By default, no `hint` is displayed. */ hint?: string; + /** + * Whether this option is disabled. + * Disabled options are visible but cannot be selected. + * + * By default, options are not disabled. + */ + disabled?: boolean; } : { /** @@ -48,6 +55,13 @@ export type Option = Value extends Primitive * By default, no `hint` is displayed. */ hint?: string; + /** + * Whether this option is disabled. + * Disabled options are visible but cannot be selected. + * + * By default, options are not disabled. + */ + disabled?: boolean; }; export interface SelectOptions extends CommonOptions { @@ -58,9 +72,16 @@ export interface SelectOptions extends CommonOptions { } export const select = (opts: SelectOptions) => { - const opt = (option: Option, state: 'inactive' | 'active' | 'selected' | 'cancelled') => { + const opt = ( + option: Option, + state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled' + ) => { const label = option.label ?? String(option.value); switch (state) { + case 'disabled': + return `${color.gray(S_RADIO_INACTIVE)} ${color.gray(label)}${ + option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : '' + }`; case 'selected': return `${color.dim(label)}`; case 'active': @@ -92,13 +113,16 @@ export const select = (opts: SelectOptions) => { 'cancelled' )}\n${color.gray(S_BAR)}`; default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ + const prefix = `${color.cyan(S_BAR)} `; + return `${title}${prefix}${limitOptions({ output: opts.output, cursor: this.cursor, options: this.options, maxItems: opts.maxItems, - style: (item, active) => opt(item, active ? 'active' : 'inactive'), - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + columnPadding: prefix.length, + style: (item, active) => + opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), + }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`; } } }, diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap index bdb20f98..a0028fd4 100644 --- a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap @@ -240,6 +240,32 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` ] `; +exports[`multiselect (isCI = false) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ opt0 +│ ◻ opt1 +│ ◻ opt2 (Hint 2) +└ +", + "", + "", + "", + "│ ◼ opt1", + "", + "", + "", + "", + "◇ foo +│ opt1", + " +", + "", +] +`; + exports[`multiselect (isCI = false) > renders message 1`] = ` [ "", @@ -850,6 +876,32 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` ] `; +exports[`multiselect (isCI = true) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ opt0 +│ ◻ opt1 +│ ◻ opt2 (Hint 2) +└ +", + "", + "", + "", + "│ ◼ opt1", + "", + "", + "", + "", + "◇ foo +│ opt1", + " +", + "", +] +`; + exports[`multiselect (isCI = true) > renders message 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index 05d3d2d2..bd8238d0 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -63,6 +63,27 @@ exports[`select (isCI = false) > down arrow selects next option 1`] = ` ] `; +exports[`select (isCI = false) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 (Hint 2) +└ +", + "", + "", + "", + "◇ foo +│ Option 1", + " +", + "", +] +`; + exports[`select (isCI = false) > renders option hints 1`] = ` [ "", @@ -220,6 +241,27 @@ exports[`select (isCI = true) > down arrow selects next option 1`] = ` ] `; +exports[`select (isCI = true) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 (Hint 2) +└ +", + "", + "", + "", + "◇ foo +│ Option 1", + " +", + "", +] +`; + exports[`select (isCI = true) > renders option hints 1`] = ` [ "", diff --git a/packages/prompts/test/multi-select.test.ts b/packages/prompts/test/multi-select.test.ts index cd524001..95412ade 100644 --- a/packages/prompts/test/multi-select.test.ts +++ b/packages/prompts/test/multi-select.test.ts @@ -315,4 +315,25 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('renders disabled options', async () => { + const result = prompts.multiselect({ + message: 'foo', + options: [ + { value: 'opt0', disabled: true }, + { value: 'opt1' }, + { value: 'opt2', disabled: true, hint: 'Hint 2' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['opt1']); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index a9c93fc1..24cbc726 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -145,4 +145,24 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('renders disabled options', async () => { + const result = prompts.select({ + message: 'foo', + options: [ + { value: 'opt0', label: 'Option 0', disabled: true }, + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2', disabled: true, hint: 'Hint 2' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('opt1'); + expect(output.buffer).toMatchSnapshot(); + }); });