Skip to content
73 changes: 73 additions & 0 deletions packages/checkbox/checkbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1380,4 +1380,77 @@ describe('checkbox prompt', () => {
expect(getScreen()).toMatchInlineSnapshot(`"✔ Select package managers npm, yarn"`);
});
});

describe('keybindings', () => {
it('supports vim keybindings when vim is in the keybindings array', async () => {
const { events, getScreen } = await render(checkbox, {
message: 'Select items',
choices: [
{ value: 'one', name: 'One' },
{ value: 'two', name: 'Two' },
],
theme: {
keybindings: ['vim'],
},
});

// Down
events.keypress('j');
expect(getScreen()).toContain('❯◯ Two');

// Up
events.keypress('k');
expect(getScreen()).toContain('❯◯ One');
});

it('supports emacs keybindings when emacs is in the keybindings array', async () => {
const { events, getScreen } = await render(checkbox, {
message: 'Select items',
choices: [
{ value: 'one', name: 'One' },
{ value: 'two', name: 'Two' },
],
theme: {
keybindings: ['emacs'],
},
});

// Down
events.keypress({ name: 'n', ctrl: true });
expect(getScreen()).toContain('❯◯ Two');

// Up
events.keypress({ name: 'p', ctrl: true });
expect(getScreen()).toContain('❯◯ One');
});

it('supports both vim and emacs keybindings when both are in the keybindings array', async () => {
const { events, getScreen } = await render(checkbox, {
message: 'Select items',
choices: [
{ value: 'one', name: 'One' },
{ value: 'two', name: 'Two' },
],
theme: {
keybindings: ['vim', 'emacs'],
},
});

// Vim: Down
events.keypress('j');
expect(getScreen()).toContain('❯◯ Two');

// Vim: Up
events.keypress('k');
expect(getScreen()).toContain('❯◯ One');

// Emacs: Down
events.keypress({ name: 'n', ctrl: true });
expect(getScreen()).toContain('❯◯ Two');

// Emacs: Up
events.keypress({ name: 'p', ctrl: true });
expect(getScreen()).toContain('❯◯ One');
});
});
});
12 changes: 8 additions & 4 deletions packages/checkbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Separator,
type Theme,
type Status,
type Keybinding,
} from '@inquirer/core';
import { cursorHide } from '@inquirer/ansi';
import type { PartialDeep } from '@inquirer/type';
Expand All @@ -40,6 +41,7 @@ type CheckboxTheme = {
| 'never'
/** @deprecated 'auto' is an alias to 'always' */
| 'auto';
keybindings: ReadonlyArray<Keybinding>;
};

type CheckboxShortcuts = {
Expand All @@ -60,6 +62,7 @@ const checkboxTheme: CheckboxTheme = {
description: (text: string) => colors.cyan(text),
},
helpMode: 'always',
keybindings: [],
};

type Choice<Value> = {
Expand Down Expand Up @@ -171,6 +174,7 @@ export default createPrompt(
} = config;
const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts };
const theme = makeTheme<CheckboxTheme>(checkboxTheme, config.theme);
const { keybindings } = theme;
const [status, setStatus] = useState<Status>('idle');
const prefix = usePrefix({ status, theme });
const [items, setItems] = useState<ReadonlyArray<Item<Value>>>(
Expand Down Expand Up @@ -205,13 +209,13 @@ export default createPrompt(
} else {
setError(isValid || 'You must select a valid value');
}
} else if (isUpKey(key) || isDownKey(key)) {
} else if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) {
if (
loop ||
(isUpKey(key) && active !== bounds.first) ||
(isDownKey(key) && active !== bounds.last)
(isUpKey(key, keybindings) && active !== bounds.first) ||
(isDownKey(key, keybindings) && active !== bounds.last)
) {
const offset = isUpKey(key) ? -1 : 1;
const offset = isUpKey(key, keybindings) ? -1 : 1;
let next = active;
do {
next = (next + offset + items.length) % items.length;
Expand Down
42 changes: 42 additions & 0 deletions packages/core/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,3 +769,45 @@ describe('Separator', () => {
expect(new Separator('===').separator).toEqual('===');
});
});

describe('keybindings', () => {
it('supports vim keybindings when vim is in the keybindings array', () => {
expect(isUpKey({ name: 'up', ctrl: false }, ['vim'])).toBeTruthy();
expect(isUpKey({ name: 'k', ctrl: false }, ['vim'])).toBeTruthy();
expect(isUpKey({ name: 'p', ctrl: true }, ['vim'])).toBeFalsy(); // Ctrl+P is emacs, not vim

expect(isDownKey({ name: 'down', ctrl: false }, ['vim'])).toBeTruthy();
expect(isDownKey({ name: 'j', ctrl: false }, ['vim'])).toBeTruthy();
expect(isDownKey({ name: 'n', ctrl: true }, ['vim'])).toBeFalsy(); // Ctrl+N is emacs, not vim
});

it('supports emacs keybindings when emacs is in the keybindings array', () => {
expect(isUpKey({ name: 'up', ctrl: false }, ['emacs'])).toBeTruthy();
expect(isUpKey({ name: 'k', ctrl: false }, ['emacs'])).toBeFalsy(); // k is vim, not emacs
expect(isUpKey({ name: 'p', ctrl: true }, ['emacs'])).toBeTruthy();

expect(isDownKey({ name: 'down', ctrl: false }, ['emacs'])).toBeTruthy();
expect(isDownKey({ name: 'j', ctrl: false }, ['emacs'])).toBeFalsy(); // j is vim, not emacs
expect(isDownKey({ name: 'n', ctrl: true }, ['emacs'])).toBeTruthy();
});

it('supports both vim and emacs keybindings when both are in the keybindings array', () => {
expect(isUpKey({ name: 'up', ctrl: false }, ['vim', 'emacs'])).toBeTruthy();
expect(isUpKey({ name: 'k', ctrl: false }, ['vim', 'emacs'])).toBeTruthy();
expect(isUpKey({ name: 'p', ctrl: true }, ['vim', 'emacs'])).toBeTruthy();

expect(isDownKey({ name: 'down', ctrl: false }, ['vim', 'emacs'])).toBeTruthy();
expect(isDownKey({ name: 'j', ctrl: false }, ['vim', 'emacs'])).toBeTruthy();
expect(isDownKey({ name: 'n', ctrl: true }, ['vim', 'emacs'])).toBeTruthy();
});

it('does not support vim and emac bindings by default', () => {
expect(isUpKey({ name: 'up', ctrl: false })).toBeTruthy();
expect(isUpKey({ name: 'k', ctrl: false })).toBeFalsy();
expect(isUpKey({ name: 'p', ctrl: true })).toBeFalsy();

expect(isDownKey({ name: 'down', ctrl: false })).toBeTruthy();
expect(isDownKey({ name: 'j', ctrl: false })).toBeFalsy();
expect(isDownKey({ name: 'n', ctrl: true })).toBeFalsy();
});
});
12 changes: 11 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
export * from './lib/key.ts';
export {
isUpKey,
isDownKey,
isSpaceKey,
isBackspaceKey,
isTabKey,
isNumberKey,
isEnterKey,
type KeypressEvent,
type Keybinding,
} from './lib/key.ts';
export * from './lib/errors.ts';
export { usePrefix } from './lib/use-prefix.ts';
export { useState } from './lib/use-state.ts';
Expand Down
24 changes: 22 additions & 2 deletions packages/core/src/lib/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,29 @@ export type KeypressEvent = {
ctrl: boolean;
};

export const isUpKey = (key: KeypressEvent): boolean => key.name === 'up';
export type Keybinding = 'emacs' | 'vim';

export const isDownKey = (key: KeypressEvent): boolean => key.name === 'down';
export const isUpKey = (
key: KeypressEvent,
keybindings: ReadonlyArray<Keybinding> = [],
): boolean =>
// The up key
key.name === 'up' ||
// Vim keybinding: hjkl keys map to left/down/up/right
(keybindings.includes('vim') && key.name === 'k') ||
// Emacs keybinding: Ctrl+P means "previous" in Emacs navigation conventions
(keybindings.includes('emacs') && key.ctrl && key.name === 'p');

export const isDownKey = (
key: KeypressEvent,
keybindings: ReadonlyArray<Keybinding> = [],
): boolean =>
// The down key
key.name === 'down' ||
// Vim keybinding: hjkl keys map to left/down/up/right
(keybindings.includes('vim') && key.name === 'j') ||
// Emacs keybinding: Ctrl+N means "next" in Emacs navigation conventions
(keybindings.includes('emacs') && key.ctrl && key.name === 'n');

export const isSpaceKey = (key: KeypressEvent): boolean => key.name === 'space';

Expand Down
19 changes: 13 additions & 6 deletions packages/select/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
makeTheme,
type Theme,
type Status,
type Keybinding,
} from '@inquirer/core';
import { cursorHide } from '@inquirer/ansi';
import type { PartialDeep } from '@inquirer/type';
Expand All @@ -35,6 +36,7 @@ type SelectTheme = {
/** @deprecated 'auto' is an alias to 'always' */
| 'auto';
indexMode: 'hidden' | 'number';
keybindings: ReadonlyArray<Keybinding>;
};

const selectTheme: SelectTheme = {
Expand All @@ -45,6 +47,7 @@ const selectTheme: SelectTheme = {
},
helpMode: 'always',
indexMode: 'hidden',
keybindings: [],
};

type Choice<Value> = {
Expand Down Expand Up @@ -125,10 +128,15 @@ export default createPrompt(
<Value>(config: SelectConfig<Value>, done: (value: Value) => void) => {
const { loop = true, pageSize = 7 } = config;
const theme = makeTheme<SelectTheme>(selectTheme, config.theme);
const { keybindings } = theme;
const [status, setStatus] = useState<Status>('idle');
const prefix = usePrefix({ status, theme });
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();

// Vim keybindings (j/k) conflict with typing those letters in search,
// so search must be disabled when vim bindings are enabled
const searchEnabled = !keybindings.includes('vim');

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

const bounds = useMemo(() => {
Expand Down Expand Up @@ -164,14 +172,14 @@ export default createPrompt(
if (isEnterKey(key)) {
setStatus('done');
done(selectedChoice.value);
} else if (isUpKey(key) || isDownKey(key)) {
} else if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) {
rl.clearLine(0);
if (
loop ||
(isUpKey(key) && active !== bounds.first) ||
(isDownKey(key) && active !== bounds.last)
(isUpKey(key, keybindings) && active !== bounds.first) ||
(isDownKey(key, keybindings) && active !== bounds.last)
) {
const offset = isUpKey(key) ? -1 : 1;
const offset = isUpKey(key, keybindings) ? -1 : 1;
let next = active;
do {
next = (next + offset + items.length) % items.length;
Expand Down Expand Up @@ -200,8 +208,7 @@ export default createPrompt(
}, 700);
} else if (isBackspaceKey(key)) {
rl.clearLine(0);
} else {
// Default to search
} else if (searchEnabled) {
const searchTerm = rl.line.toLowerCase();
const matchIndex = items.findIndex((item) => {
if (Separator.isSeparator(item) || !isSelectable(item)) return false;
Expand Down
Loading