Skip to content

Commit

Permalink
Feat(@inquirer/select): Allow string choices (backward compat with v9…
Browse files Browse the repository at this point in the history
… & prior)

Ref #1527
  • Loading branch information
SBoudrias committed Sep 1, 2024
1 parent ce5d3b6 commit 4860cdf
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 25 deletions.
11 changes: 7 additions & 4 deletions packages/core/src/lib/Separator.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ export class Separator {
}
}

static isSeparator(
choice: undefined | Separator | Record<string, unknown>,
): 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',
);
}
}
2 changes: 2 additions & 0 deletions packages/select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions packages/select/select.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
77 changes: 56 additions & 21 deletions packages/select/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,73 @@ type Choice<Value> = {
type?: never;
};

type SelectConfig<Value> = {
type NormalizedChoice<Value> = {
value: Value;
name: string;
description?: string;
short: string;
disabled: boolean | string;
};

type SelectConfig<
Value,
ChoicesObject =
| ReadonlyArray<string | Separator>
| ReadonlyArray<Choice<Value> | Separator>,
> = {
message: string;
choices: ReadonlyArray<Choice<Value> | Separator>;
choices: ChoicesObject extends ReadonlyArray<string | Separator>
? ChoicesObject
: ReadonlyArray<Choice<Value> | Separator>;
pageSize?: number;
loop?: boolean;
default?: unknown;
theme?: PartialDeep<Theme<SelectTheme>>;
};

type Item<Value> = Separator | Choice<Value>;

function isSelectable<Value>(item: Item<Value>): item is Choice<Value> {
function isSelectable<Value>(
item: NormalizedChoice<Value> | Separator,
): item is NormalizedChoice<Value> {
return !Separator.isSeparator(item) && !item.disabled;
}

function normalizeChoices<Value>(
choices: ReadonlyArray<string | Separator> | ReadonlyArray<Choice<Value> | Separator>,
): Array<NormalizedChoice<Value> | 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(
<Value,>(config: SelectConfig<Value>, 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>(selectTheme, config.theme);
const prefix = usePrefix({ theme });
const [status, setStatus] = useState('pending');
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();

const items = useMemo(() => normalizeChoices(config.choices), [config.choices]);

const bounds = useMemo(() => {
const first = items.findIndex(isSelectable);
const last = items.findLastIndex(isSelectable);
Expand All @@ -98,7 +141,7 @@ export default createPrompt(
);

// Safe to assume the cursor position always point to a Choice.
const selectedChoice = items[active] as Choice<Value>;
const selectedChoice = items[active] as NormalizedChoice<Value>;

useKeypress((key, rl) => {
clearTimeout(searchTimeoutRef.current);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -174,36 +215,30 @@ export default createPrompt(
}
}

const page = usePagination<Item<Value>>({
const page = usePagination({
items,
active,
renderItem({ item, isActive }: { item: Item<Value>; 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
Expand Down

0 comments on commit 4860cdf

Please sign in to comment.