Skip to content

Commit 46f7b8a

Browse files
gfargoclaude
andcommitted
feat: add keyMap prop for selective key group disabling
Ink does not support event propagation stopping — all useInput handlers in the app receive every keypress simultaneously. The new keyMap prop lets consumers opt individual key groups out of the component's handler without affecting any parent-level bindings. Groups: arrows, vimKeys, homeEnd, cancel, select, toggle (all default true). isFocused={false} remains the way to disable all input at once. Exports KeyMap type from package root. 9 new tests; 142 total passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 08fbf51 commit 46f7b8a

4 files changed

Lines changed: 297 additions & 14 deletions

File tree

readme.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,45 @@ When typing:
213213
- Hotkeys are disabled (characters go to the search query)
214214
- "No matches" is shown when the query matches nothing
215215

216+
### Avoiding Key Conflicts (`keyMap`)
217+
218+
Because Ink does not support event propagation stopping, every `useInput` handler in your app receives every keypress simultaneously. If your application already binds one of the component's default keys globally, you can disable individual key groups with the `keyMap` prop — the component ignores those keys without interfering with your own handlers.
219+
220+
```tsx
221+
// j/k are used by a parent vim-style navigator — disable them here
222+
<EnhancedSelectInput
223+
items={items}
224+
keyMap={{ vimKeys: false }}
225+
onSelect={onSelect}
226+
/>
227+
228+
// Parent handles Escape itself — don't fire onCancel
229+
<EnhancedSelectInput
230+
items={items}
231+
keyMap={{ cancel: false }}
232+
onSelect={onSelect}
233+
/>
234+
235+
// Arrows-only navigation — disable vim keys, Home/End, and Space toggle
236+
<EnhancedSelectInput
237+
items={items}
238+
multiple
239+
keyMap={{ vimKeys: false, homeEnd: false, toggle: false }}
240+
onConfirm={onConfirm}
241+
/>
242+
```
243+
244+
| `keyMap` field | Keys it controls | Default |
245+
|---|---|---|
246+
| `arrows` | `` `` `` `` | `true` |
247+
| `vimKeys` | `j` `k` (vertical) · `h` `l` (horizontal) | `true` |
248+
| `homeEnd` | `Home` · `End` | `true` |
249+
| `cancel` | `Escape``onCancel` | `true` |
250+
| `select` | `Enter``onSelect` / `onConfirm` | `true` |
251+
| `toggle` | `Space` toggle in multi-select mode | `true` |
252+
253+
Any field not supplied stays enabled. `isFocused={false}` remains the way to disable all input at once.
254+
216255
### Custom Components
217256

218257
```tsx
@@ -294,6 +333,7 @@ The hook accepts all the same props as `EnhancedSelectInput` except `indicatorCo
294333
| `groupHeaderComponent` | `FC<GroupHeaderProps>` | `DefaultGroupHeaderComponent` | Custom group header renderer |
295334
| `searchable` | `boolean` | `false` | Enable inline search/filter mode |
296335
| `searchPlaceholder` | `string` | `'Search...'` | Placeholder text shown when search query is empty |
336+
| `keyMap` | `KeyMap` | all enabled | Selectively disable built-in key groups to avoid conflicts |
297337

298338
### Item Shape
299339

src/enhanced-select-input/index.tsx

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,43 @@ export type Item<V> = {
2020
group?: string
2121
}
2222

23+
/**
24+
* Fine-grained control over which key groups the component reacts to.
25+
*
26+
* Because Ink does not support event propagation stopping, every `useInput`
27+
* handler in the app receives every keypress simultaneously. If your
28+
* application already binds one of these keys globally, set the corresponding
29+
* flag to `false` so the component ignores it without interfering with your
30+
* own handler.
31+
*
32+
* All groups default to `true` (enabled). Only the keys you explicitly set to
33+
* `false` are disabled — the rest keep their default behaviour.
34+
*
35+
* @example
36+
* // Disable vim keys — j/k/h/l are used by the parent app
37+
* <EnhancedSelectInput keyMap={{ vimKeys: false }} ... />
38+
*
39+
* // Disable Escape — the parent handles cancel itself
40+
* <EnhancedSelectInput keyMap={{ cancel: false }} ... />
41+
*
42+
* // Disable both vim keys and Home/End
43+
* <EnhancedSelectInput keyMap={{ vimKeys: false, homeEnd: false }} ... />
44+
*/
45+
export type KeyMap = {
46+
/** Arrow key navigation (↑ ↓ ← →). Default: true. */
47+
readonly arrows?: boolean
48+
/** Vim-style navigation keys (j/k in vertical, h/l in horizontal). Default: true. */
49+
readonly vimKeys?: boolean
50+
/** Home / End jump-to-boundary keys. Default: true. */
51+
readonly homeEnd?: boolean
52+
/** Escape → onCancel. Default: true. */
53+
readonly cancel?: boolean
54+
/** Enter → onSelect / onConfirm. Default: true. */
55+
readonly select?: boolean
56+
/** Space toggle in multi-select mode. Default: true. */
57+
readonly toggle?: boolean
58+
}
59+
2360
/** Props accepted by the useEnhancedSelectInput hook (all behaviour, no rendering). */
2461
export type UseEnhancedSelectInputProperties<V> = {
2562
readonly items: Array<Item<V>>
@@ -54,6 +91,12 @@ export type UseEnhancedSelectInputProperties<V> = {
5491
* navigation keys are disabled in this mode.
5592
*/
5693
readonly searchable?: boolean
94+
/**
95+
* Selectively disable built-in key groups to avoid conflicts with
96+
* keybindings registered elsewhere in your application.
97+
* See {@link KeyMap} for available groups and defaults.
98+
*/
99+
readonly keyMap?: KeyMap
57100
}
58101

59102
/** Full component props — hook props plus rendering customisation. */
@@ -192,7 +235,17 @@ export function useEnhancedSelectInput<V>({
192235
onConfirm,
193236
onToggle,
194237
searchable = false,
238+
keyMap,
195239
}: UseEnhancedSelectInputProperties<V>): UseEnhancedSelectInputResult<V> {
240+
// Resolve full key map — any flag not supplied defaults to enabled (true).
241+
const km = {
242+
arrows: keyMap?.arrows ?? true,
243+
vimKeys: keyMap?.vimKeys ?? true,
244+
homeEnd: keyMap?.homeEnd ?? true,
245+
cancel: keyMap?.cancel ?? true,
246+
select: keyMap?.select ?? true,
247+
toggle: keyMap?.toggle ?? true,
248+
}
196249
const [searchQuery, setSearchQuery] = useState('')
197250

198251
// Filter items based on search query
@@ -306,30 +359,30 @@ export function useEnhancedSelectInput<V>({
306359

307360
if (!hasItems && !searchable) return
308361

309-
// eslint-disable-next-line unicorn/prevent-abbreviations
310362
const navKeys =
311363
orientation === 'vertical' ? VERTICAL_NAV_KEYS : HORIZONTAL_NAV_KEYS
312-
// eslint-disable-next-line unicorn/prevent-abbreviations
313-
const isNavKey = !searchable && navKeys.has(input)
364+
// A vim key is only "active" when vimKeys are enabled and we're not in
365+
// searchable mode (where every character is search input).
366+
const isActiveVimKey = km.vimKeys && !searchable && navKeys.has(input)
314367

315-
if (key.home) {
368+
if (km.homeEnd && key.home) {
316369
updateSelection(findFirstValidIndex(filteredItems))
317370
return
318371
}
319372

320-
if (key.end) {
373+
if (km.homeEnd && key.end) {
321374
updateSelection(findLastValidIndex(filteredItems))
322375
return
323376
}
324377

325-
if (key.escape) {
378+
if (km.cancel && key.escape) {
326379
onCancel?.()
327380
return
328381
}
329382

330383
// Space: toggle in multi-select mode (but not in searchable mode
331384
// where space is a valid search character)
332-
if (multiple && !searchable && input === ' ') {
385+
if (km.toggle && multiple && !searchable && input === ' ') {
333386
const item = filteredItems[selectedIndex]
334387
if (item && !item.disabled) {
335388
const k = itemKey(item)
@@ -349,19 +402,31 @@ export function useEnhancedSelectInput<V>({
349402
let nextIndex = selectedIndex
350403

351404
if (orientation === 'vertical') {
352-
if (key.upArrow || (!searchable && input === 'k')) {
405+
if (
406+
(km.arrows && key.upArrow) ||
407+
(km.vimKeys && !searchable && input === 'k')
408+
) {
353409
nextIndex = findNextValidIndex(filteredItems, selectedIndex, -1)
354410
}
355411

356-
if (key.downArrow || (!searchable && input === 'j')) {
412+
if (
413+
(km.arrows && key.downArrow) ||
414+
(km.vimKeys && !searchable && input === 'j')
415+
) {
357416
nextIndex = findNextValidIndex(filteredItems, selectedIndex, 1)
358417
}
359418
} else {
360-
if (key.leftArrow || (!searchable && input === 'h')) {
419+
if (
420+
(km.arrows && key.leftArrow) ||
421+
(km.vimKeys && !searchable && input === 'h')
422+
) {
361423
nextIndex = findNextValidIndex(filteredItems, selectedIndex, -1)
362424
}
363425

364-
if (key.rightArrow || (!searchable && input === 'l')) {
426+
if (
427+
(km.arrows && key.rightArrow) ||
428+
(km.vimKeys && !searchable && input === 'l')
429+
) {
365430
nextIndex = findNextValidIndex(filteredItems, selectedIndex, 1)
366431
}
367432
}
@@ -370,7 +435,7 @@ export function useEnhancedSelectInput<V>({
370435
updateSelection(nextIndex)
371436
}
372437

373-
if (key.return) {
438+
if (km.select && key.return) {
374439
if (multiple) {
375440
// In multi-select mode Enter confirms the full selection
376441
const confirmed = filteredItems.filter((item) =>
@@ -396,9 +461,9 @@ export function useEnhancedSelectInput<V>({
396461
return
397462
}
398463

399-
// Hotkeys: nav keys for the active orientation take priority.
464+
// Hotkeys: active vim nav keys take priority over item hotkeys.
400465
// Hotkeys are not active in multi-select or searchable mode.
401-
if (!multiple && !searchable && !isNavKey) {
466+
if (km.select && !multiple && !searchable && !isActiveVimKey) {
402467
const hotkeyItem = filteredItems.find(
403468
(item) => item.hotkey === input && !item.disabled
404469
)

src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212

1313
export type {
1414
Item,
15+
KeyMap,
1516
Properties,
1617
UseEnhancedSelectInputProperties,
1718
UseEnhancedSelectInputResult,

0 commit comments

Comments
 (0)