diff --git a/packages/checkbox/README.md b/packages/checkbox/README.md index 4394758ca..a8ff806b8 100644 --- a/packages/checkbox/README.md +++ b/packages/checkbox/README.md @@ -41,6 +41,7 @@ const answer = await checkbox({ | choices | `Array<{ value: string, name?: string, disabled?: boolean \| string, checked?: boolean } \| Separator>` | yes | List of the available choices. The `value` will be returned as the answer, and used as display if no `name` is defined. Choices who're `disabled` will be displayed, but not selectable. | | pageSize | `number` | no | By default, lists of choice longer than 7 will be paginated. Use this option to control how many choices will appear on the screen at once. | | loop | `boolean` | no | Defaults to `true`. When set to `false`, the cursor will be constrained to the top and bottom of the choice list without looping. | +| required | `boolean` | no | When set to `true`, ensures at least one choice must be selected. | The `Separator` object can be used to render non-selectable lines in the choice list. By default it'll render a line, but you can provide the text as argument (`new Separator('-- Dependencies --')`). This option is often used to add labels to groups within long list of options. diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index d0818e429..9ba19f1fd 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -624,4 +624,43 @@ describe('checkbox prompt', () => { '"[checkbox prompt] No selectable choices. All choices are disabled."', ); }); + + it('shows validation message if user did not select any choice', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: numberedChoices, + required: true, + }); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices) + > At least one choice must be selected" + `); + + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ❯◉ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual([1]); + }); }); diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index b222182d9..ce8c3f550 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -32,6 +32,7 @@ type Config = PromptConfig<{ instructions?: string | boolean; choices: ReadonlyArray | Separator>; loop?: boolean; + required?: boolean; }>; type Item = Separator | Choice; @@ -74,7 +75,14 @@ function renderItem({ item, isActive }: { item: Item; isActive: bo export default createPrompt( (config: Config, done: (value: Array) => void) => { - const { prefix = usePrefix(), instructions, pageSize, loop = true, choices } = config; + const { + prefix = usePrefix(), + instructions, + pageSize, + loop = true, + choices, + required, + } = config; const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), @@ -96,11 +104,16 @@ export default createPrompt( const [active, setActive] = useState(bounds.first); const [showHelpTip, setShowHelpTip] = useState(true); + const [errorMsg, setError] = useState(undefined); useKeypress((key) => { if (isEnterKey(key)) { - setStatus('done'); - done(items.filter(isChecked).map((choice) => choice.value)); + if (required && !items.some(isChecked)) { + setError('At least one choice must be selected'); + } else { + setStatus('done'); + done(items.filter(isChecked).map((choice) => choice.value)); + } } else if (isUpKey(key) || isDownKey(key)) { if (!loop && active === bounds.first && isUpKey(key)) return; if (!loop && active === bounds.last && isDownKey(key)) return; @@ -111,6 +124,7 @@ export default createPrompt( } while (!isSelectable(items[next]!)); setActive(next); } else if (isSpaceKey(key)) { + setError(undefined); setShowHelpTip(false); setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice))); } else if (key.name === 'a') { @@ -162,7 +176,12 @@ export default createPrompt( } } - return `${prefix} ${message}${helpTip}\n${page}${ansiEscapes.cursorHide}`; + let error = ''; + if (errorMsg) { + error = chalk.red(`> ${errorMsg}`); + } + + return `${prefix} ${message}${helpTip}\n${page}\n${error}${ansiEscapes.cursorHide}`; }, );