diff --git a/packages/checkbox/README.md b/packages/checkbox/README.md index 30c5ac27a..86a611cb2 100644 --- a/packages/checkbox/README.md +++ b/packages/checkbox/README.md @@ -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. @@ -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. diff --git a/packages/checkbox/checkbox.test.ts b/packages/checkbox/checkbox.test.ts index b860a696e..a165e0a8a 100644 --- a/packages/checkbox/checkbox.test.ts +++ b/packages/checkbox/checkbox.test.ts @@ -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 to select, to toggle all, to invert + selection, and to proceed) + ◯ 1 + ◯ 2 + ◯ 3 + ❯◉ 4 + ◯ 5 + ◯ 6 + ◯ 7" + `); + + events.keypress('b'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ◉ 1 + ◉ 2 + ◉ 3 + ❯◉ 4 + ◉ 5 + ◉ 6 + ◉ 7" + `); + + events.keypress('b'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and 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 to select, to toggle all, to invert + selection, and 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 to select, and 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); + }); }); diff --git a/packages/checkbox/src/index.ts b/packages/checkbox/src/index.ts index 524fec17a..36aa3c495 100644 --- a/packages/checkbox/src/index.ts +++ b/packages/checkbox/src/index.ts @@ -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), @@ -92,6 +97,7 @@ type CheckboxConfig< choices: ReadonlyArray>, ) => boolean | string | Promise; theme?: PartialDeep>; + shortcuts?: CheckboxShortcuts; }; type Item = NormalizedChoice | Separator; @@ -151,6 +157,7 @@ export default createPrompt( required, validate = () => true, } = config; + const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts }; const theme = makeTheme(checkboxTheme, config.theme); const firstRender = useRef(true); const [status, setStatus] = useState('idle'); @@ -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 @@ -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 (