Skip to content

Commit

Permalink
Feat: support customize shortcuts for checkbox selection/deselection (#…
Browse files Browse the repository at this point in the history
…1663)

---------

Co-authored-by: Simon Boudrias <admin@simonboudrias.com>
  • Loading branch information
PoHengLinTW and SBoudrias authored Feb 2, 2025
1 parent 9f535bd commit 504c230
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 14 deletions.
30 changes: 21 additions & 9 deletions packages/checkbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,16 @@ const answer = await checkbox({

## Options

| Property | Type | Required | Description |
| -------- | --------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| message | `string` | yes | The question to ask |
| choices | `Choice[]` | yes | List of the available choices. |
| pageSize | `number` | no | By default, lists of choice longer than 7 will be paginated. Use this option to control how many choices will appear on the screen at once. |
| loop | `boolean` | no | Defaults to `true`. When set to `false`, the cursor will be constrained to the top and bottom of the choice list without looping. |
| required | `boolean` | no | When set to `true`, ensures at least one choice must be selected. |
| validate | `async (Choice[]) => boolean \| string` | no | On submit, validate the choices. When returning a string, it'll be used as the error message displayed to the user. Note: returning a rejected promise, we'll assume a code error happened and crash. |
| theme | [See Theming](#Theming) | no | Customize look of the prompt. |
| Property | Type | Required | Description |
| --------- | --------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| message | `string` | yes | The question to ask |
| choices | `Choice[]` | yes | List of the available choices. |
| pageSize | `number` | no | By default, lists of choice longer than 7 will be paginated. Use this option to control how many choices will appear on the screen at once. |
| loop | `boolean` | no | Defaults to `true`. When set to `false`, the cursor will be constrained to the top and bottom of the choice list without looping. |
| required | `boolean` | no | When set to `true`, ensures at least one choice must be selected. |
| validate | `async (Choice[]) => boolean \| string` | no | On submit, validate the choices. When returning a string, it'll be used as the error message displayed to the user. Note: returning a rejected promise, we'll assume a code error happened and crash. |
| shortcuts | [See Shortcuts](#Shortcuts) | no | Customize shortcut keys for `all` and `invert`. |
| 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.

Expand Down Expand Up @@ -126,6 +127,17 @@ Also note the `choices` array can contain `Separator`s to help organize long lis

`choices` can also be an array of string, in which case the string will be used both as the `value` and the `name`.

## Shortcuts

You can customize the shortcut keys for `all` and `invert` or disable them by setting them to `null`.

```ts
type Shortcuts = {
all?: string | null; // default: 'a'
invert?: string | null; // default: 'i'
};
```

## 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
116 changes: 116 additions & 0 deletions packages/checkbox/checkbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,4 +1124,120 @@ describe('checkbox prompt', () => {
expect(getScreen()).toMatchInlineSnapshot(`"✔ Select a number 2"`);
});
});

describe('shortcuts', () => {
it('allow select all with customized key', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: numberedChoices,
shortcuts: {
all: 'b',
},
});

events.keypress('4');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <b> to toggle all, <i> to invert
selection, and <enter> to proceed)
◯ 1
◯ 2
◯ 3
❯◉ 4
◯ 5
◯ 6
◯ 7"
`);

events.keypress('b');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <b> to toggle all, <i> to invert
selection, and <enter> to proceed)
◉ 1
◉ 2
◉ 3
❯◉ 4
◉ 5
◉ 6
◉ 7"
`);

events.keypress('b');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <b> to toggle all, <i> to invert
selection, and <enter> to proceed)
◯ 1
◯ 2
◯ 3
❯◯ 4
◯ 5
◯ 6
◯ 7"
`);

events.keypress('b');
events.keypress('enter');
await expect(answer).resolves.toEqual(numberedChoices.map(({ value }) => value));
});
});

it('allow inverting selection with customized key', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: numberedChoices,
shortcuts: {
invert: 'j',
},
});

const unselect = [2, 4, 6, 7, 8, 11];
unselect.forEach((value) => {
events.keypress(String(value));
});
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <j> to invert
selection, and <enter> to proceed)
◯ 5
◉ 6
◉ 7
❯◉ 8
◯ 9
◯ 10
◯ 11"
`);

events.keypress('j');
events.keypress('enter');
await expect(answer).resolves.not.toContain(unselect);
});

it('disable `all` and `invert` keys', async () => {
const { events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: numberedChoices,
shortcuts: {
all: null,
invert: null,
},
});

// All options are deselected and should not change if default shortcuts are pressed
const expectedScreen = getScreen();
expect(expectedScreen).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, and <enter> to proceed)
❯◯ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
◯ 7
(Use arrow keys to reveal more choices)"
`);

events.keypress('a');
expect(getScreen()).toBe(expectedScreen);

events.keypress('i');
expect(getScreen()).toBe(expectedScreen);
});
});
19 changes: 14 additions & 5 deletions packages/checkbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ type CheckboxTheme = {
helpMode: 'always' | 'never' | 'auto';
};

type CheckboxShortcuts = {
all?: string | null;
invert?: string | null;
};

const checkboxTheme: CheckboxTheme = {
icon: {
checked: colors.green(figures.circleFilled),
Expand Down Expand Up @@ -92,6 +97,7 @@ type CheckboxConfig<
choices: ReadonlyArray<Choice<Value>>,
) => boolean | string | Promise<string | boolean>;
theme?: PartialDeep<Theme<CheckboxTheme>>;
shortcuts?: CheckboxShortcuts;
};

type Item<Value> = NormalizedChoice<Value> | Separator;
Expand Down Expand Up @@ -151,6 +157,7 @@ export default createPrompt(
required,
validate = () => true,
} = config;
const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts };
const theme = makeTheme<CheckboxTheme>(checkboxTheme, config.theme);
const firstRender = useRef(true);
const [status, setStatus] = useState<Status>('idle');
Expand Down Expand Up @@ -205,10 +212,10 @@ export default createPrompt(
setError(undefined);
setShowHelpTip(false);
setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice)));
} else if (key.name === 'a') {
} else if (key.name === shortcuts.all) {
const selectAll = items.some((choice) => isSelectable(choice) && !choice.checked);
setItems(items.map(check(selectAll)));
} else if (key.name === 'i') {
} else if (key.name === shortcuts.invert) {
setItems(items.map(toggle));
} else if (isNumberKey(key)) {
// Adjust index to start at 1
Expand Down Expand Up @@ -273,11 +280,13 @@ export default createPrompt(
} else {
const keys = [
`${theme.style.key('space')} to select`,
`${theme.style.key('a')} to toggle all`,
`${theme.style.key('i')} to invert selection`,
shortcuts.all ? `${theme.style.key(shortcuts.all)} to toggle all` : '',
shortcuts.invert
? `${theme.style.key(shortcuts.invert)} to invert selection`
: '',
`and ${theme.style.key('enter')} to proceed`,
];
helpTipTop = ` (Press ${keys.join(', ')})`;
helpTipTop = ` (Press ${keys.filter((key) => key !== '').join(', ')})`;
}

if (
Expand Down

0 comments on commit 504c230

Please sign in to comment.