Skip to content

Commit

Permalink
Feat(@inquirer/expand) Support separators
Browse files Browse the repository at this point in the history
Fix #1539
  • Loading branch information
SBoudrias committed Sep 6, 2024
1 parent 0c03959 commit 5c8cfc9
Show file tree
Hide file tree
Showing 3 changed files with 85 additions and 9 deletions.
2 changes: 2 additions & 0 deletions packages/expand/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion packages/expand/expand.test.mts
Original file line number Diff line number Diff line change
@@ -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 = [
{
Expand Down Expand Up @@ -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?',
Expand Down
38 changes: 30 additions & 8 deletions packages/expand/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
usePrefix,
isEnterKey,
makeTheme,
Separator,
type Theme,
} from '@inquirer/core';
import type { PartialDeep } from '@inquirer/type';
Expand Down Expand Up @@ -64,18 +65,24 @@ type ExpandConfig<
ChoicesObject = readonly { key: Key; name: string }[] | readonly Choice<Value>[],
> = {
message: string;
choices: ChoicesObject extends readonly { key: Key; name: string }[]
choices: ChoicesObject extends readonly (Separator | { key: Key; name: string })[]
? ChoicesObject
: readonly Choice<Value>[];
: readonly (Separator | Choice<Value>)[];
default?: Key | 'h';
expanded?: boolean;
theme?: PartialDeep<Theme>;
};

function normalizeChoices<Value>(
choices: readonly { key: Key; name: string }[] | readonly Choice<Value>[],
): NormalizedChoice<Value>[] {
choices:
| readonly (Separator | { key: Key; name: string })[]
| readonly (Separator | Choice<Value>)[],
): (Separator | NormalizedChoice<Value>)[] {
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 {
Expand Down Expand Up @@ -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<Value> =>
!Separator.isSeparator(choice) && choice.key === answer,
);
if (selectedChoice) {
setStatus('done');
// Set the value as we might've selected the default one.
Expand All @@ -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<Value>;
(choice): choice is NormalizedChoice<Value> =>
!Separator.isSeparator(choice) && choice.key === value.toLowerCase(),
)!;
return `${prefix} ${message} ${theme.style.answer(selectedChoice.name)}`;
}

Expand All @@ -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();
}
Expand All @@ -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);
Expand All @@ -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<Value> =>
!Separator.isSeparator(choice) && choice.key === value.toLowerCase(),
);
if (currentOption) {
helpTip = `${colors.cyan('>>')} ${currentOption.name}`;
}
Expand All @@ -184,3 +204,5 @@ export default createPrompt(
];
},
);

export { Separator } from '@inquirer/core';

0 comments on commit 5c8cfc9

Please sign in to comment.