diff --git a/packages/search/README.md b/packages/search/README.md index b98016e64..ce70ee44d 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -124,6 +124,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`. + ### Validation & autocomplete interaction The validation within the search prompt acts as a signal for the autocomplete feature. diff --git a/packages/search/search.test.mts b/packages/search/search.test.mts index 7146d226e..403b051dc 100644 --- a/packages/search/search.test.mts +++ b/packages/search/search.test.mts @@ -73,6 +73,61 @@ describe('search prompt', () => { ); }); + it('works with string results', async () => { + const choices = [ + 'Stark', + 'Lannister', + 'Targaryen', + 'Baratheon', + 'Greyjoy', + 'Martell', + 'Tyrell', + 'Arryn', + 'Tully', + ]; + + const { answer, events, getScreen } = await render(search, { + message: 'Select a family', + source: (term: string = '') => { + return choices.filter((choice) => choice.includes(term)); + }, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a family + ❯ Stark + Lannister + Targaryen + Baratheon + Greyjoy + Martell + Tyrell + (Use arrow keys to reveal more choices)" + `); + + events.keypress('down'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a family + Stark + ❯ Lannister + Targaryen + Baratheon + Greyjoy + Martell + Tyrell" + `); + + events.type('Targ'); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a family Targ + ❯ Targaryen" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual('Targaryen'); + }); + it('allows to search and navigate the list', async () => { const { answer, events, getScreen } = await render(search, { message: 'Select a Canadian province', diff --git a/packages/search/src/index.mts b/packages/search/src/index.mts index 4bece4e47..879caf17c 100644 --- a/packages/search/src/index.mts +++ b/packages/search/src/index.mts @@ -45,27 +45,64 @@ type Choice = { type?: never; }; -type SearchConfig = { +type NormalizedChoice = { + value: Value; + name: string; + description?: string; + short: string; + disabled: boolean | string; +}; + +type SearchConfig< + Value, + ChoicesObject = + | ReadonlyArray + | ReadonlyArray | Separator>, +> = { message: string; source: ( term: string | undefined, opt: { signal: AbortSignal }, - ) => - | ReadonlyArray | Separator> - | Promise | Separator>>; + ) => ChoicesObject extends ReadonlyArray + ? ChoicesObject | Promise + : + | ReadonlyArray | Separator> + | Promise | Separator>>; validate?: (value: Value) => boolean | string | Promise; pageSize?: number; theme?: PartialDeep>; }; -type Item = Separator | Choice; +type Item = Separator | NormalizedChoice; -function isSelectable(item: Item): item is Choice { +function isSelectable(item: Item): item is NormalizedChoice { return !Separator.isSeparator(item) && !item.disabled; } -function stringifyChoice(choice: Choice): string { - return choice.name ?? String(choice.value); +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( @@ -107,7 +144,7 @@ export default createPrompt( // Reset the pointer setActive(undefined); setSearchError(undefined); - setSearchResults(results); + setSearchResults(normalizeChoices(results)); setStatus('pending'); } } catch (error: unknown) { @@ -125,7 +162,7 @@ export default createPrompt( }, [searchTerm]); // Safe to assume the cursor position never points to a Separator. - const selectedChoice = searchResults[active] as Choice | void; + const selectedChoice = searchResults[active] as NormalizedChoice | void; useKeypress(async (key, rl) => { if (isEnterKey(key)) { @@ -141,8 +178,8 @@ export default createPrompt( setSearchError(isValid || 'You must provide a valid value'); } else { // Reset line with new search term - rl.write(stringifyChoice(selectedChoice)); - setSearchTerm(stringifyChoice(selectedChoice)); + rl.write(selectedChoice.name); + setSearchTerm(selectedChoice.name); } } else { // Reset the readline line value to the previous value. On line event, the value @@ -151,8 +188,8 @@ export default createPrompt( } } else if (key.name === 'tab' && selectedChoice) { rl.clearLine(0); // Remove the tab character. - rl.write(stringifyChoice(selectedChoice)); - setSearchTerm(stringifyChoice(selectedChoice)); + rl.write(selectedChoice.name); + setSearchTerm(selectedChoice.name); } else if (status !== 'searching' && (key.name === 'up' || key.name === 'down')) { rl.clearLine(0); if ( @@ -189,24 +226,23 @@ export default createPrompt( } // TODO: What to do if no results are found? Should we display a message? - const page = usePagination>({ + const page = usePagination({ items: searchResults, active, - renderItem({ item, isActive }: { item: Item; isActive: boolean }) { + renderItem({ item, isActive }) { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } - const line = stringifyChoice(item); 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: false, @@ -221,7 +257,7 @@ export default createPrompt( let searchStr; if (status === 'done' && selectedChoice) { - const answer = selectedChoice.short ?? stringifyChoice(selectedChoice); + const answer = selectedChoice.short ?? selectedChoice.name; return `${prefix} ${message} ${theme.style.answer(answer)}`; } else { searchStr = theme.style.searchTerm(searchTerm);