diff --git a/README.md b/README.md index 82f4ef3..6583a3e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,29 @@ const answer = await select({ }); ``` +### clearInputWhenSelected + +Clear the filter input when the option is selected (also causes the option list to change). + +### confirmDelete(multiple only) + +The first time you try the delete key, it will focus on the option to be deleted, and the second time it will remove the focused option. + +```ts +import { select } from 'inquirer-select-pro'; +const answer = await select({ + message: 'select', + confirmDelete: true, + options: async (input) => { + const res = await fetch('', { + body: new URLSearchParams({ keyword: input }), + }); + if (!res.ok) throw new Error('fail to get list!'); + return await res.json(); + }, +}); +``` + ## API ### select() @@ -64,7 +87,7 @@ An inquirer select that supports multiple selections and filtering #### Parameters -- `config` [**_SelectProps_**](./src/types.ts#L166) **_\_** +- `config` [**_SelectProps_**](./src/types.ts#L191) **_\_** #### Returns @@ -101,11 +124,11 @@ declare function useSelect( #### Parameters -- `props` [**_UseSelectOptions_**](./src/types.ts#L58)**_\_** +- `props` [**_UseSelectOptions_**](./src/types.ts#L63)**_\_** #### Returns -[**_UseSelectReturnValue_**](./src/types.ts#L149)**_\_** +[**_UseSelectReturnValue_**](./src/types.ts#L163)**_\_** ### Theming @@ -216,6 +239,8 @@ Parameters can also be fixed. The following parameters can be fixed: - required - loop - multiple +- canToggleAll +- confirmDelete ```bash pnpm dev filter-demo --multiple=false diff --git a/examples/cli.ts b/examples/cli.ts index 5963099..969de16 100644 --- a/examples/cli.ts +++ b/examples/cli.ts @@ -47,6 +47,7 @@ const availableOptions = [ 'loop', 'multiple', 'canToggleAll', + 'confirmDelete', ]; let whichDemo: Demos | null; diff --git a/src/index.ts b/src/index.ts index c9d2eb2..b6916cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,13 @@ const defaultSelectTheme: (multiple: boolean) => SelectTheme = (multiple) => ({ style: { disabledOption: (text: string) => chalk.dim(`-[x] ${text}`), renderSelectedOptions: (selectedOptions) => - selectedOptions.map((option) => option.name || option.value).join(', '), + selectedOptions + .map((option) => + option.focused + ? chalk.inverse(option.name || option.value) + : option.name || option.value, + ) + .join(', '), emptyText: (text) => `${chalk.blue(figures.info)} ${chalk.bold(text)}`, placeholder: (text: string) => chalk.dim(text), }, @@ -89,6 +95,7 @@ function renderHelpTip(context: SelectContext) { behaviors, multiple, canToggleAll, + focusedSelection, } = context; let helpTipTop = ''; let helpTipBottom = ''; @@ -118,7 +125,17 @@ function renderHelpTip(context: SelectContext) { } if (behaviors.select && !behaviors.deleteOption) { - keys.push(`${theme.style.key('backspace')} to remove option`); + keys.push( + `${ + theme.style.key('backspace') + + (focusedSelection >= 0 ? ` ${theme.style.highlight('again')}` : '') + } to remove option`, + ); + } + if (!behaviors.blur && focusedSelection >= 0) { + keys.push( + `${theme.style.key('up/down')} or ${theme.style.key('esc')} to exit`, + ); } if (keys.length > 0) { helpTipTop = ` (Press ${keys.join(', ')})`; @@ -140,7 +157,14 @@ function renderHelpTip(context: SelectContext) { } function renderFilterInput( - { theme, filterInput, status, placeholder }: SelectContext, + { + theme, + filterInput, + status, + placeholder, + focusedSelection, + confirmDelete, + }: SelectContext, answer: string, ) { if (status === SelectStatus.UNLOADED) return ''; @@ -150,6 +174,10 @@ function renderFilterInput( } else { input += `${answer ? `${answer} ` : ''}${filterInput}`; } + if (confirmDelete) { + input += + focusedSelection >= 0 ? ansiEscapes.cursorHide : ansiEscapes.cursorShow; + } return input; } diff --git a/src/types.ts b/src/types.ts index e8a0348..1ab783a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,11 @@ export type SelectOption = { disabled?: boolean | string; }; +/** + * @internal + */ +export type SelectedOption = SelectOption & { focused?: boolean }; + export enum SelectStatus { UNLOADED = 'unloaded', FILTERING = 'filtering', @@ -33,7 +38,7 @@ export type SelectTheme = { style: { disabledOption: (text: string) => string; renderSelectedOptions: ( - selectedOptions: ReadonlyArray>, + selectedOptions: ReadonlyArray>, allOptions: ReadonlyArray>, ) => string; emptyText: (text: string) => string; @@ -74,6 +79,14 @@ export interface UseSelectOptions { * `false` */ clearInputWhenSelected?: boolean; + + /** + * Only valid when multiple is true, confirmation is required when deleting selectable options + * + * @defaultValue + * `false` + */ + confirmDelete?: boolean; /** * Enable toggle all options * @@ -144,10 +157,13 @@ export interface SelectBehaviors { setCursor: boolean; filter: boolean; deleteOption: boolean; + blur: boolean; } export interface UseSelectReturnValue { selections: SelectOption[]; + focusedSelection: number; + confirmDelete: boolean; filterInput: string; displayItems: ReadonlyArray>; cursor: number; diff --git a/src/useSelect.ts b/src/useSelect.ts index c105374..144d5cf 100644 --- a/src/useSelect.ts +++ b/src/useSelect.ts @@ -11,7 +11,9 @@ import { } from '@inquirer/core'; import { check, + isDirectionKey, isDownKey, + isEscKey, isSelectAllKey, isSelectable, isTabKey, @@ -25,6 +27,7 @@ import { type SelectOption, SelectStatus, type SelectValue, + type SelectedOption, type UseSelectOptions, type UseSelectReturnValue, } from './types'; @@ -53,7 +56,8 @@ function transformDefaultValue( ({ name: value2Name(value), value, - }) satisfies SelectOption, + focused: false, + }) satisfies SelectedOption, ); } return [ @@ -91,6 +95,7 @@ export function useSelect( defaultValue, clearInputWhenSelected = false, canToggleAll = false, + confirmDelete = false, inputDelay = 200, validate = () => true, equals = (a, b) => a === b, @@ -116,7 +121,8 @@ export function useSelect( const loader = useRef>(); const [filterInput, setFilterInput] = useState(''); - const selections = useRef[]>( + const [focused, setFocused] = useState(-1); + const selections = useRef[]>( transformDefaultValue(defaultValue, multiple), ); @@ -127,6 +133,7 @@ export function useSelect( filter: false, setCursor: false, deleteOption: false, + blur: false, }); function setBehavior(key: keyof SelectBehaviors, value: boolean) { @@ -221,6 +228,12 @@ export function useSelect( if (selections.current.length <= 0) return; const lastIndex = selections.current.length - 1; const lastSection = selections.current[lastIndex]; + // enter focus mode + if (confirmDelete && focused < 0) { + lastSection.focused = true; + setFocused(lastIndex); + return; + } const ss = selections.current.slice(0, lastIndex); setBehavior('deleteOption', true); selections.current = ss; @@ -254,6 +267,20 @@ export function useSelect( } useKeypress(async (key, rl) => { + if (focused >= 0) { + if (isBackspaceKey(key)) { + removeLastSection(); + setFocused(-1); + } else if (isDirectionKey(key) || isEscKey(key)) { + // quit focus mode + const focusedSelection = selections.current[focused]; + focusedSelection.focused = false; + setFocused(-1); + setBehavior('blur', true); + } + clearFilterInput(rl); + return; + } if (isEnterKey(key)) { if (status !== SelectStatus.LOADED) { return; @@ -367,6 +394,8 @@ export function useSelect( return { selections: selections.current, + focusedSelection: focused, + confirmDelete, filterInput, displayItems, cursor, diff --git a/src/utils.ts b/src/utils.ts index 360daee..b1c5eb8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,6 +10,19 @@ export function isDownKey(key: KeypressEvent) { return key.name === 'down'; } +export function isDirectionKey(key: KeypressEvent) { + return ( + key.name === 'up' || + key.name === 'down' || + key.name === 'left' || + key.name === 'right' + ); +} + +export function isEscKey(key: KeypressEvent) { + return key.name === 'escape'; +} + export function isTabKey(key: KeypressEvent) { return key.name === 'tab'; } diff --git a/tests/__snapshots__/index.spec.ts.snap b/tests/__snapshots__/index.spec.ts.snap index 0edd7d4..7897048 100644 --- a/tests/__snapshots__/index.spec.ts.snap +++ b/tests/__snapshots__/index.spec.ts.snap @@ -51,6 +51,52 @@ exports[`inquirer-select-pro > interactions > should toggle all options when pre (Use arrow keys to reveal more options)" `; +exports[`inquirer-select-pro > interactions > should work as expected when confirmDelete=true 1`] = ` +"? Choose movie: (Press to remove option) +>> The Shawshank Redemption (1994) + [✔] The Shawshank Redemption (1994) +>[ ] The Godfather (1972) + [ ] The Godfather: Part II (1974) + [ ] The Dark Knight (2008)" +`; + +exports[`inquirer-select-pro > interactions > should work as expected when confirmDelete=true 2`] = ` +"? Choose movie: (Press again to remove option, or to + exit) +>> The Shawshank Redemption (1994) + [✔] The Shawshank Redemption (1994) +>[ ] The Godfather (1972) + [ ] The Godfather: Part II (1974) + [ ] The Dark Knight (2008)" +`; + +exports[`inquirer-select-pro > interactions > should work as expected when confirmDelete=true 3`] = ` +"? Choose movie: (Press to remove option) +>> The Shawshank Redemption (1994) + [✔] The Shawshank Redemption (1994) +>[ ] The Godfather (1972) + [ ] The Godfather: Part II (1974) + [ ] The Dark Knight (2008)" +`; + +exports[`inquirer-select-pro > interactions > should work as expected when confirmDelete=true 4`] = ` +"? Choose movie: (Press again to remove option) +>> The Shawshank Redemption (1994) + [✔] The Shawshank Redemption (1994) +>[ ] The Godfather (1972) + [ ] The Godfather: Part II (1974) + [ ] The Dark Knight (2008)" +`; + +exports[`inquirer-select-pro > interactions > should work as expected when confirmDelete=true 5`] = ` +"? Choose movie: +>> Type to search + [ ] The Shawshank Redemption (1994) +>[ ] The Godfather (1972) + [ ] The Godfather: Part II (1974) + [ ] The Dark Knight (2008)" +`; + exports[`inquirer-select-pro > interactions > should work when options is a function and filter disabled 1`] = ` "? Choose movie: (Press to select/deselect, to proceed) >[ ] The Shawshank Redemption (1994) diff --git a/tests/index.spec.ts b/tests/index.spec.ts index d30439a..20d9c60 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -279,6 +279,27 @@ describe('inquirer-select-pro', () => { events.keypress('enter'); await expect(answer).resolves.toHaveLength(4); }); + + it('should work as expected when confirmDelete=true', async () => { + await renderPrompt({ + message, + options: quickRemoteData, + pageSize: 4, + confirmDelete: true, + }); + await waitForInteraction(); + events.keypress('tab'); + events.keypress('down'); + expect(getScreen()).toMatchSnapshot(); + events.keypress('backspace'); + expect(getScreen()).toMatchSnapshot(); + events.keypress('escape'); + expect(getScreen()).toMatchSnapshot(); + events.keypress('backspace'); + expect(getScreen()).toMatchSnapshot(); + events.keypress('backspace'); + expect(getScreen()).toMatchSnapshot(); + }); }); describe('appearance', () => {