diff --git a/packages/expand/README.md b/packages/expand/README.md index a62195dc5..85f28fe4a 100644 --- a/packages/expand/README.md +++ b/packages/expand/README.md @@ -94,6 +94,8 @@ const answer = await expand({ | expanded | `boolean` | no | Expand the choices by default | | theme | [See Theming](#Theming) | no | Customize look of the prompt. | +`Separator` objects can be used in the `choices` array 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. + ### `Choice` object The `Choice` object is typed as diff --git a/packages/expand/expand.test.mts b/packages/expand/expand.test.mts index c33b35856..0491276aa 100644 --- a/packages/expand/expand.test.mts +++ b/packages/expand/expand.test.mts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render } from '@inquirer/testing'; -import expand from './src/index.mjs'; +import expand, { Separator } from './src/index.mjs'; const overwriteChoices = [ { @@ -144,6 +144,58 @@ describe('expand prompt', () => { await expect(answer).resolves.toEqual('abort'); }); + it('supports separators', async () => { + const { answer, events, getScreen } = await render(expand, { + message: 'Overwrite this file?', + choices: [ + { + value: 'Yarn', + key: 'y', + }, + new Separator(), + { + value: 'npm', + key: 'n', + }, + ], + }); + + expect(getScreen()).toMatchInlineSnapshot(`"? Overwrite this file? (ynH)"`); + + events.type('h'); + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Overwrite this file? h + y) Yarn + ────────────── + n) npm" + `); + + events.type('y'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Overwrite this file? y + y) Yarn + ────────────── + n) npm + >> Yarn" + `); + + events.keypress('backspace'); + events.type('n'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Overwrite this file? n + y) Yarn + ────────────── + n) npm + >> npm" + `); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(`"? Overwrite this file? npm"`); + + await expect(answer).resolves.toEqual('npm'); + }); + it('selects without value', async () => { const { answer, events, getScreen } = await render(expand, { message: 'Overwrite this file?', diff --git a/packages/expand/src/index.mts b/packages/expand/src/index.mts index d1ec68aef..96f2ae905 100644 --- a/packages/expand/src/index.mts +++ b/packages/expand/src/index.mts @@ -6,6 +6,7 @@ import { usePrefix, isEnterKey, makeTheme, + Separator, type Theme, } from '@inquirer/core'; import type { PartialDeep } from '@inquirer/type'; @@ -64,18 +65,24 @@ type ExpandConfig< ChoicesObject = readonly { key: Key; name: string }[] | readonly Choice[], > = { message: string; - choices: ChoicesObject extends readonly { key: Key; name: string }[] + choices: ChoicesObject extends readonly (Separator | { key: Key; name: string })[] ? ChoicesObject - : readonly Choice[]; + : readonly (Separator | Choice)[]; default?: Key | 'h'; expanded?: boolean; theme?: PartialDeep; }; function normalizeChoices( - choices: readonly { key: Key; name: string }[] | readonly Choice[], -): NormalizedChoice[] { + choices: + | readonly (Separator | { key: Key; name: string })[] + | readonly (Separator | Choice)[], +): (Separator | NormalizedChoice)[] { return choices.map((choice) => { + if (Separator.isSeparator(choice)) { + return choice; + } + const name: string = 'name' in choice ? choice.name : String(choice.value); const value = 'value' in choice ? choice.value : name; return { @@ -109,7 +116,10 @@ export default createPrompt( if (answer === 'h' && !expanded) { setExpanded(true); } else { - const selectedChoice = choices.find(({ key }) => key === answer); + const selectedChoice = choices.find( + (choice): choice is NormalizedChoice => + !Separator.isSeparator(choice) && choice.key === answer, + ); if (selectedChoice) { setStatus('done'); // Set the value as we might've selected the default one. @@ -132,8 +142,9 @@ export default createPrompt( if (status === 'done') { // If the prompt is done, it's safe to assume there is a selected value. const selectedChoice = choices.find( - ({ key }) => key === value, - ) as NormalizedChoice; + (choice): choice is NormalizedChoice => + !Separator.isSeparator(choice) && choice.key === value.toLowerCase(), + )!; return `${prefix} ${message} ${theme.style.answer(selectedChoice.name)}`; } @@ -143,6 +154,8 @@ export default createPrompt( let longChoices = ''; let shortChoices = allChoices .map((choice) => { + if (Separator.isSeparator(choice)) return ''; + if (choice.key === defaultKey) { return choice.key.toUpperCase(); } @@ -157,6 +170,10 @@ export default createPrompt( shortChoices = ''; longChoices = allChoices .map((choice) => { + if (Separator.isSeparator(choice)) { + return ` ${choice.separator}`; + } + const line = ` ${choice.key}) ${choice.name}`; if (choice.key === value.toLowerCase()) { return theme.style.highlight(line); @@ -168,7 +185,10 @@ export default createPrompt( } let helpTip = ''; - const currentOption = allChoices.find(({ key }) => key === value.toLowerCase()); + const currentOption = choices.find( + (choice): choice is NormalizedChoice => + !Separator.isSeparator(choice) && choice.key === value.toLowerCase(), + ); if (currentOption) { helpTip = `${colors.cyan('>>')} ${currentOption.name}`; } @@ -184,3 +204,5 @@ export default createPrompt( ]; }, ); + +export { Separator } from '@inquirer/core';