From 4860cdf394a526227fadbe56020b14c9c5fbe5a1 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Fri, 30 Aug 2024 17:15:31 -0400 Subject: [PATCH] Feat(@inquirer/select): Allow string choices (backward compat with v9 & prior) Ref #1527 --- packages/core/src/lib/Separator.mts | 11 +++-- packages/select/README.md | 2 + packages/select/select.test.mts | 19 +++++++ packages/select/src/index.mts | 77 +++++++++++++++++++++-------- 4 files changed, 84 insertions(+), 25 deletions(-) diff --git a/packages/core/src/lib/Separator.mts b/packages/core/src/lib/Separator.mts index dec18a093..c78128198 100644 --- a/packages/core/src/lib/Separator.mts +++ b/packages/core/src/lib/Separator.mts @@ -16,9 +16,12 @@ export class Separator { } } - static isSeparator( - choice: undefined | Separator | Record, - ): choice is Separator { - return Boolean(choice && choice.type === 'separator'); + static isSeparator(choice: unknown): choice is Separator { + return Boolean( + choice && + typeof choice === 'object' && + 'type' in choice && + choice.type === 'separator', + ); } } diff --git a/packages/select/README.md b/packages/select/README.md index b4adc3fd9..fae706983 100644 --- a/packages/select/README.md +++ b/packages/select/README.md @@ -118,6 +118,8 @@ Here's each property: - `short`: Once the prompt is done (press enter), we'll use `short` if defined to render next to the question. By default we'll use `name`. - `disabled`: Disallow the option from being selected. If `disabled` is a string, it'll be used as a help tip explaining why the choice isn't available. +`choices` can also be an array of string, in which case the string will be used both as the `value` and the `name`. + ## Theming You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index b53ce7ca3..d5bd2a44d 100644 --- a/packages/select/select.test.mts +++ b/packages/select/select.test.mts @@ -79,6 +79,25 @@ describe('select prompt', () => { expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"'); }); + it('allow passing strings as choices', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select one', + choices: ['Option A', 'Option B', 'Option C'], + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select one (Use arrow keys) + ❯ Option A + Option B + Option C" + `); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(`"? Select one Option A"`); + + await expect(answer).resolves.toEqual('Option A'); + }); + it('use number key to select an option', async () => { const { answer, events, getScreen } = await render(select, { message: 'Select a number', diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 2c6a65ec0..4a3959c1e 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -49,30 +49,73 @@ type Choice = { type?: never; }; -type SelectConfig = { +type NormalizedChoice = { + value: Value; + name: string; + description?: string; + short: string; + disabled: boolean | string; +}; + +type SelectConfig< + Value, + ChoicesObject = + | ReadonlyArray + | ReadonlyArray | Separator>, +> = { message: string; - choices: ReadonlyArray | Separator>; + choices: ChoicesObject extends ReadonlyArray + ? ChoicesObject + : ReadonlyArray | Separator>; pageSize?: number; loop?: boolean; default?: unknown; theme?: PartialDeep>; }; -type Item = Separator | Choice; - -function isSelectable(item: Item): item is Choice { +function isSelectable( + item: NormalizedChoice | Separator, +): item is NormalizedChoice { return !Separator.isSeparator(item) && !item.disabled; } +function normalizeChoices( + choices: ReadonlyArray | ReadonlyArray | Separator>, +): Array | Separator> { + return choices.map((choice) => { + if (Separator.isSeparator(choice)) return choice; + + if (typeof choice === 'string') { + return { + value: choice as Value, + name: choice, + short: choice, + disabled: false, + }; + } + + const name = choice.name ?? String(choice.value); + return { + value: choice.value, + name, + description: choice.description, + short: choice.short ?? name, + disabled: choice.disabled ?? false, + }; + }); +} + export default createPrompt( (config: SelectConfig, done: (value: Value) => void) => { - const { choices: items, loop = true, pageSize = 7 } = config; + const { loop = true, pageSize = 7 } = config; const firstRender = useRef(true); const theme = makeTheme(selectTheme, config.theme); const prefix = usePrefix({ theme }); const [status, setStatus] = useState('pending'); const searchTimeoutRef = useRef>(); + const items = useMemo(() => normalizeChoices(config.choices), [config.choices]); + const bounds = useMemo(() => { const first = items.findIndex(isSelectable); const last = items.findLastIndex(isSelectable); @@ -98,7 +141,7 @@ export default createPrompt( ); // Safe to assume the cursor position always point to a Choice. - const selectedChoice = items[active] as Choice; + const selectedChoice = items[active] as NormalizedChoice; useKeypress((key, rl) => { clearTimeout(searchTimeoutRef.current); @@ -135,9 +178,7 @@ export default createPrompt( const matchIndex = items.findIndex((item) => { if (Separator.isSeparator(item) || !isSelectable(item)) return false; - return String(item.name || item.value) - .toLowerCase() - .startsWith(searchTerm); + return item.name.toLowerCase().startsWith(searchTerm); }); if (matchIndex >= 0) { @@ -174,36 +215,30 @@ export default createPrompt( } } - const page = usePagination>({ + const page = usePagination({ items, active, - renderItem({ item, isActive }: { item: Item; isActive: boolean }) { + renderItem({ item, isActive }) { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } - const line = String(item.name || item.value); if (item.disabled) { const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - return theme.style.disabled(`${line} ${disabledLabel}`); + return theme.style.disabled(`${item.name} ${disabledLabel}`); } const color = isActive ? theme.style.highlight : (x: string) => x; const cursor = isActive ? theme.icon.cursor : ` `; - return color(`${cursor} ${line}`); + return color(`${cursor} ${item.name}`); }, pageSize, loop, }); if (status === 'done') { - const answer = - selectedChoice.short ?? - selectedChoice.name ?? - // TODO: Could we enforce that at the type level? Name should be defined for non-string values. - String(selectedChoice.value); - return `${prefix} ${message} ${theme.style.answer(answer)}`; + return `${prefix} ${message} ${theme.style.answer(selectedChoice.short)}`; } const choiceDescription = selectedChoice.description