Skip to content

Commit aca72bb

Browse files
gfargoclaude
andauthored
feat: add multi-select mode (#12) (#24)
Space toggles the highlighted item; Enter confirms the full selection via onConfirm. defaultSelectedKeys pre-populates checked state. onToggle fires on each individual toggle. Hotkeys are disabled in multi-select mode to avoid Space ambiguity. DefaultIndicatorComponent renders [x]/[ ] checkboxes when isChecked is provided. isChecked is threaded through to both custom indicatorComponent and itemComponent. 13 new tests cover all paths. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 29dcacd commit aca72bb

3 files changed

Lines changed: 444 additions & 32 deletions

File tree

readme.md

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ An enhanced, customizable select input component for [Ink](https://github.com/va
1717
- **Keyboard Navigation:** Arrow keys and Vim-like keys (`h/j/k/l`) supported.
1818
- **Hooks for Highlight & Selection:** Run custom logic on highlight and selection changes.
1919
- **Limit Displayed Items:** Restrict how many options to show at once.
20+
- **Multi-select Mode:** Space to toggle, Enter to confirm a multi-item selection.
2021

2122
## Compatibility
2223

@@ -77,6 +78,41 @@ render(<Demo />)
7778
/>
7879
```
7980

81+
### Multi-select
82+
83+
Enable multi-select mode with the `multiple` prop. Space toggles an item; Enter confirms the full selection.
84+
85+
```tsx
86+
import React, { useState } from 'react'
87+
import { render, Text } from 'ink'
88+
import { EnhancedSelectInput } from 'ink-enhanced-select-input'
89+
90+
const options = [
91+
{ label: 'TypeScript', value: 'ts' },
92+
{ label: 'React', value: 'react' },
93+
{ label: 'Ink', value: 'ink' },
94+
{ label: 'Legacy (unsupported)', value: 'legacy', disabled: true },
95+
]
96+
97+
function MultiDemo() {
98+
return (
99+
<EnhancedSelectInput
100+
items={options}
101+
multiple
102+
defaultSelectedKeys={['ts']}
103+
onToggle={(item, checked) =>
104+
console.log(`${item.label} is now ${checked ? 'checked' : 'unchecked'}`)
105+
}
106+
onConfirm={(selected) =>
107+
console.log('Confirmed:', selected.map((i) => i.value))
108+
}
109+
/>
110+
)
111+
}
112+
113+
render(<MultiDemo />)
114+
```
115+
80116
### Per-Item Indicators
81117

82118
```tsx
@@ -144,23 +180,27 @@ function MyCustomSelect({ items, onSelect }) {
144180
}
145181
```
146182

147-
The hook accepts all the same props as `EnhancedSelectInput` except `indicatorComponent`, `itemComponent`, and `showScrollIndicators`. It returns `{ selectedIndex, rotateIndex, visibleItems, hasItems, itemsAbove, itemsBelow }`.
183+
The hook accepts all the same props as `EnhancedSelectInput` except `indicatorComponent`, `itemComponent`, and `showScrollIndicators`. It returns `{ selectedIndex, rotateIndex, visibleItems, hasItems, itemsAbove, itemsBelow, checkedKeys }`. `checkedKeys` is a `Set<string>` of checked item keys — only populated when `multiple` is `true`.
148184

149185
## Props
150186

151-
| Prop | Type | Default | Description |
152-
| -------------------- | ---------------------------- | --------------------------- | ---------------------------------------- |
153-
| `items` | `Array<Item<V>>` | _required_ | List of selectable items |
154-
| `isFocused` | `boolean` | `true` | Whether the component responds to input |
155-
| `initialIndex` | `number` | `0` | Index of the initially highlighted item |
156-
| `limit` | `number` || Max number of visible items |
157-
| `indicatorComponent` | `FC<IndicatorProps>` | `DefaultIndicatorComponent` | Custom selection indicator |
158-
| `itemComponent` | `FC<ItemProps>` | `DefaultItemComponent` | Custom item renderer |
159-
| `onSelect` | `(item: Item<V>) => void` || Called on selection (Enter or hotkey) |
160-
| `onHighlight` | `(item: Item<V>) => void` || Called when the highlighted item changes |
161-
| `onCancel` | `() => void` || Called when Escape is pressed |
162-
| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction |
163-
| `showScrollIndicators` | `boolean` | `false` | Show ▲/▼ or ◀/▶ counts when `limit` clips the list |
187+
| Prop | Type | Default | Description |
188+
| ---------------------- | --------------------------------- | --------------------------- | ---------------------------------------- |
189+
| `items` | `Array<Item<V>>` | _required_ | List of selectable items |
190+
| `isFocused` | `boolean` | `true` | Whether the component responds to input |
191+
| `initialIndex` | `number` | `0` | Index of the initially highlighted item |
192+
| `limit` | `number` || Max number of visible items |
193+
| `indicatorComponent` | `FC<IndicatorProps>` | `DefaultIndicatorComponent` | Custom selection indicator |
194+
| `itemComponent` | `FC<ItemProps>` | `DefaultItemComponent` | Custom item renderer |
195+
| `onSelect` | `(item: Item<V>) => void` || Called on selection (Enter or hotkey) — single-select only |
196+
| `onHighlight` | `(item: Item<V>) => void` || Called when the highlighted item changes |
197+
| `onCancel` | `() => void` || Called when Escape is pressed |
198+
| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction |
199+
| `showScrollIndicators` | `boolean` | `false` | Show ▲/▼ or ◀/▶ counts when `limit` clips the list |
200+
| `multiple` | `boolean` | `false` | Enable multi-select mode (Space toggles, Enter confirms) |
201+
| `defaultSelectedKeys` | `string[]` || Pre-checked item keys for multi-select |
202+
| `onConfirm` | `(items: Array<Item<V>>) => void` || Called on Enter in multi-select mode with all checked items |
203+
| `onToggle` | `(item: Item<V>, checked: boolean) => void` || Called each time an item is toggled in multi-select mode |
164204

165205
### Item Shape
166206

@@ -183,12 +223,14 @@ type Item<V> = {
183223
184224
## Keyboard Navigation
185225
186-
| Orientation | Previous | Next | First | Last | Select | Cancel |
187-
| ----------- | --------- | --------- | -------- | ------- | ------- | -------- |
188-
| Vertical | `` / `k` | `` / `j` | `Home` | `End` | `Enter` | `Escape` |
189-
| Horizontal | `` / `h` | `` / `l` | `Home` | `End` | `Enter` | `Escape` |
226+
| Orientation | Previous | Next | First | Last | Select / Confirm | Toggle (multi) | Cancel |
227+
| ----------- | --------- | --------- | -------- | ------- | ---------------- | -------------- | -------- |
228+
| Vertical | `` / `k` | `` / `j` | `Home` | `End` | `Enter` | `Space` | `Escape` |
229+
| Horizontal | `` / `h` | `` / `l` | `Home` | `End` | `Enter` | `Space` | `Escape` |
230+
231+
In **single-select** mode, `Enter` calls `onSelect` and hotkeys select immediately. In **multi-select** mode (`multiple={true}`), `Space` toggles the highlighted item and `Enter` calls `onConfirm` with all checked items. Hotkeys are disabled in multi-select mode to avoid ambiguity with `Space`.
190232
191-
Hotkeys (when assigned) select the item immediately. Disabled items are automatically skipped during navigation, including by `Home` and `End`.
233+
Disabled items are automatically skipped during navigation, including by `Home` and `End`.
192234
193235
`Escape` calls the `onCancel` prop when provided — useful for multi-step CLI flows that need a "go back" action.
194236

src/enhanced-select-input/index.tsx

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ export type UseEnhancedSelectInputProperties<V> = {
2626
/** Called when Escape is pressed while the component is focused. */
2727
readonly onCancel?: () => void
2828
readonly orientation?: 'vertical' | 'horizontal'
29+
/** Enable multi-select mode. Space toggles, Enter confirms. */
30+
readonly multiple?: boolean
31+
/**
32+
* Pre-selected item keys in multi-select mode.
33+
* Each entry should match an item's `key` field (or `String(value)` fallback).
34+
*/
35+
readonly defaultSelectedKeys?: string[]
36+
/**
37+
* Called when the user confirms a multi-select (Enter).
38+
* Only used when `multiple` is true.
39+
*/
40+
readonly onConfirm?: (items: Array<Item<V>>) => void
41+
/**
42+
* Called each time an item is toggled in multi-select mode (Space).
43+
* Receives the toggled item and whether it is now checked.
44+
*/
45+
readonly onToggle?: (item: Item<V>, checked: boolean) => void
2946
}
3047

3148
/** Full component props — hook props plus rendering customisation. */
@@ -42,6 +59,8 @@ export type Properties<V> = UseEnhancedSelectInputProperties<V> & {
4259

4360
export type IndicatorProperties = {
4461
readonly isSelected: boolean
62+
/** True when the item is checked in multi-select mode. Undefined in single-select mode. */
63+
readonly isChecked?: boolean
4564
// eslint-disable-next-line react/no-unused-prop-types
4665
readonly item: Item<unknown>
4766
}
@@ -50,6 +69,8 @@ export type ItemProperties = {
5069
readonly isSelected: boolean
5170
readonly label: string
5271
readonly isDisabled: boolean
72+
/** True when the item is checked in multi-select mode. Undefined in single-select mode. */
73+
readonly isChecked?: boolean
5374
}
5475

5576
// Vim navigation keys that take precedence over hotkeys.
@@ -109,6 +130,10 @@ export function findLastValidIndex<V>(items: Array<Item<V>>): number {
109130
return items.length - 1
110131
}
111132

133+
function itemKey<V>(item: Item<V>): string {
134+
return item.key ?? String(item.value)
135+
}
136+
112137
export type UseEnhancedSelectInputResult<V> = {
113138
/** Index of the currently highlighted item within the full items array. */
114139
selectedIndex: number
@@ -122,6 +147,8 @@ export type UseEnhancedSelectInputResult<V> = {
122147
itemsAbove: number
123148
/** Number of items hidden below the current window. */
124149
itemsBelow: number
150+
/** Keys of checked items. Only populated in multi-select mode. */
151+
checkedKeys: Set<string>
125152
}
126153

127154
/**
@@ -138,12 +165,19 @@ export function useEnhancedSelectInput<V>({
138165
onHighlight,
139166
onCancel,
140167
orientation = 'vertical',
168+
multiple = false,
169+
defaultSelectedKeys,
170+
onConfirm,
171+
onToggle,
141172
}: UseEnhancedSelectInputProperties<V>): UseEnhancedSelectInputResult<V> {
142173
const safeInitialIndex = resolveInitialIndex(items, initialIndex)
143174
const [selectedIndex, setSelectedIndex] = useState(safeInitialIndex)
144175
const [rotateIndex, setRotateIndex] = useState(
145176
limit ? Math.floor(safeInitialIndex / limit) * limit : 0
146177
)
178+
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(
179+
() => new Set(defaultSelectedKeys ?? [])
180+
)
147181

148182
const hasItems = items.length > 0
149183
const visibleItems = limit
@@ -161,7 +195,7 @@ export function useEnhancedSelectInput<V>({
161195
// String(value) to produce "[object Object]" for every item.
162196
useEffect(() => {
163197
if (process.env['NODE_ENV'] !== 'production' && items.length > 0) {
164-
const keys = items.map((item) => item.key ?? String(item.value))
198+
const keys = items.map((item) => itemKey(item))
165199
const seen = new Set<string>()
166200
const duplicates = new Set<string>()
167201
for (const k of keys) {
@@ -230,6 +264,24 @@ export function useEnhancedSelectInput<V>({
230264
return
231265
}
232266

267+
// Space: toggle in multi-select mode
268+
if (multiple && input === ' ') {
269+
const item = items[selectedIndex]
270+
if (item && !item.disabled) {
271+
const k = itemKey(item)
272+
setCheckedKeys((prev) => {
273+
const next = new Set(prev)
274+
const nowChecked = !next.has(k)
275+
if (nowChecked) next.add(k)
276+
else next.delete(k)
277+
onToggle?.(item, nowChecked)
278+
return next
279+
})
280+
}
281+
282+
return
283+
}
284+
233285
let nextIndex = selectedIndex
234286

235287
if (orientation === 'vertical') {
@@ -255,15 +307,21 @@ export function useEnhancedSelectInput<V>({
255307
}
256308

257309
if (key.return) {
258-
const selectedItem = items[selectedIndex]
259-
if (selectedItem && !selectedItem.disabled) {
260-
onSelect?.(selectedItem)
310+
if (multiple) {
311+
// In multi-select mode Enter confirms the full selection
312+
const confirmed = items.filter((item) => checkedKeys.has(itemKey(item)))
313+
onConfirm?.(confirmed)
314+
} else {
315+
const selectedItem = items[selectedIndex]
316+
if (selectedItem && !selectedItem.disabled) {
317+
onSelect?.(selectedItem)
318+
}
261319
}
262320
}
263321

264322
// Hotkeys: nav keys for the active orientation take priority.
265-
// See README "Keyboard Navigation" for reserved key constraints.
266-
if (!isNavKey) {
323+
// Hotkeys are not active in multi-select mode to avoid ambiguity.
324+
if (!multiple && !isNavKey) {
267325
const hotkeyItem = items.find(
268326
(item) => item.hotkey === input && !item.disabled
269327
)
@@ -277,10 +335,33 @@ export function useEnhancedSelectInput<V>({
277335
{ isActive: isFocused }
278336
)
279337

280-
return { selectedIndex, rotateIndex, visibleItems, hasItems, itemsAbove, itemsBelow }
338+
return {
339+
selectedIndex,
340+
rotateIndex,
341+
visibleItems,
342+
hasItems,
343+
itemsAbove,
344+
itemsBelow,
345+
checkedKeys,
346+
}
281347
}
282348

283-
export function DefaultIndicatorComponent({ isSelected }: IndicatorProperties) {
349+
export function DefaultIndicatorComponent({
350+
isSelected,
351+
isChecked,
352+
}: IndicatorProperties) {
353+
if (isChecked !== undefined) {
354+
// Multi-select mode: show checkbox + cursor
355+
return (
356+
<Box marginRight={1}>
357+
<Text color={isSelected ? 'green' : undefined}>
358+
{isChecked ? '[x]' : '[ ]'}
359+
</Text>
360+
</Box>
361+
)
362+
}
363+
364+
// Single-select mode: classic arrow cursor
284365
return (
285366
<Box marginRight={1}>
286367
<Text color={isSelected ? 'green' : undefined}>
@@ -312,8 +393,15 @@ export function EnhancedSelectInput<V>({
312393
// All remaining props are forwarded to the hook
313394
...hookProps
314395
}: Properties<V>) {
315-
const { selectedIndex, rotateIndex, visibleItems, hasItems, itemsAbove, itemsBelow } =
316-
useEnhancedSelectInput(hookProps)
396+
const {
397+
selectedIndex,
398+
rotateIndex,
399+
visibleItems,
400+
hasItems,
401+
itemsAbove,
402+
itemsBelow,
403+
checkedKeys,
404+
} = useEnhancedSelectInput(hookProps)
317405

318406
if (!hasItems) {
319407
return <Box />
@@ -322,6 +410,7 @@ export function EnhancedSelectInput<V>({
322410
const IndicatorComponent = indicatorComponent
323411
const ItemComponent = itemComponent
324412
const isVertical = hookProps.orientation !== 'horizontal'
413+
const isMultiple = hookProps.multiple === true
325414

326415
return (
327416
<Box flexDirection={isVertical ? 'column' : 'row'}>
@@ -338,22 +427,30 @@ export function EnhancedSelectInput<V>({
338427
>
339428
{visibleItems.map((item, index) => {
340429
const isSelected = index + rotateIndex === selectedIndex
430+
const isChecked = isMultiple
431+
? checkedKeys.has(item.key ?? String(item.value))
432+
: undefined
341433

342434
return (
343435
<Box key={item.key ?? String(item.value)}>
344-
{item.indicator ? (
436+
{item.indicator && !isMultiple ? (
345437
<Box marginRight={1}>
346438
<Text>{isSelected ? item.indicator : ' '}</Text>
347439
</Box>
348440
) : (
349-
<IndicatorComponent isSelected={isSelected} item={item} />
441+
<IndicatorComponent
442+
isSelected={isSelected}
443+
isChecked={isChecked}
444+
item={item}
445+
/>
350446
)}
351447
<ItemComponent
352448
isSelected={isSelected}
353449
label={item.label}
354450
isDisabled={Boolean(item.disabled)}
451+
isChecked={isChecked}
355452
/>
356-
{item.hotkey && (
453+
{item.hotkey && !isMultiple && (
357454
<Text dimColor color="gray">
358455
{' '}
359456
({item.hotkey})

0 commit comments

Comments
 (0)