From 6786c2ac2abdca94255222431e5aaaed41cb0c4f Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 16:16:11 -0400 Subject: [PATCH 1/5] refactor: remove duplicate searchable prop and default export - Properties already inherits searchable from UseEnhancedSelectInputProperties - Remove redundant declaration in Properties - Component now reads searchable from hookProperties directly - Remove default export (only named export is re-exported from package entry) --- src/enhanced-select-input/index.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/enhanced-select-input/index.tsx b/src/enhanced-select-input/index.tsx index 99734c7..13efa0d 100644 --- a/src/enhanced-select-input/index.tsx +++ b/src/enhanced-select-input/index.tsx @@ -68,13 +68,6 @@ export type Properties = UseEnhancedSelectInputProperties & { */ // eslint-disable-next-line react/boolean-prop-naming readonly showScrollIndicators?: boolean - /** - * Enable searchable/filterable mode. When true, printable characters - * build a search query that filters items by label. Hotkeys and vim - * navigation keys are disabled in this mode. - */ - // eslint-disable-next-line react/boolean-prop-naming - readonly searchable?: boolean /** Placeholder text shown in the search input when the query is empty. */ readonly searchPlaceholder?: string } @@ -484,7 +477,6 @@ export function EnhancedSelectInput({ itemComponent = DefaultItemComponent, groupHeaderComponent = DefaultGroupHeaderComponent, showScrollIndicators = false, - searchable = false, searchPlaceholder = 'Search...', // All remaining props are forwarded to the hook ...hookProperties @@ -498,7 +490,9 @@ export function EnhancedSelectInput({ itemsBelow, checkedKeys, searchQuery, - } = useEnhancedSelectInput({ ...hookProperties, searchable }) + } = useEnhancedSelectInput(hookProperties) + + const searchable = hookProperties.searchable === true if (!hasItems && !searchable) { return @@ -607,5 +601,3 @@ export function EnhancedSelectInput({ ) } - -export default EnhancedSelectInput From b3d1116d8601396d43f94210c5b963a1d408d827 Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 16:16:44 -0400 Subject: [PATCH 2/5] chore: add exports field to package.json, fix tsconfig comment - Add 'exports' map with types and import conditions for modern ESM resolution - Add 'types' field pointing to dist/index.d.ts - Fix tsconfig target comment: was 'Node.js 14', now 'Node.js 20+' --- package.json | 7 +++++++ tsconfig.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e3704c0..f74c9c2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,13 @@ "license": "MIT", "main": "dist/index.js", "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "type": "module", "engines": { "node": ">=20" diff --git a/tsconfig.json b/tsconfig.json index 2b835bb..ea35ce0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "module": "Node16", "moduleResolution": "node16", "moduleDetection": "force", - "target": "ES2020", // Node.js 14 + "target": "ES2020", // Node.js 20+ "lib": ["DOM", "DOM.Iterable", "ES2020"], "allowSyntheticDefaultImports": true, // To provide backwards compatibility, Node.js allows you to import most CommonJS packages with a default import. This flag tells TypeScript that it's okay to use import on CommonJS modules. "resolveJsonModule": false, // ESM doesn't yet support JSON modules. From 752be022073af17db0b5c454a98a4a756da3aa5f Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 16:17:59 -0400 Subject: [PATCH 3/5] chore: update storybook to demo all features Add demos for item groups, multi-select, searchable mode, and scroll indicators. The main menu now uses groups itself to categorize demos into 'Classic' and 'New in 1.0' sections. --- src/storybook.tsx | 231 +++++++++++++++++++++++++++++++++------------- 1 file changed, 165 insertions(+), 66 deletions(-) diff --git a/src/storybook.tsx b/src/storybook.tsx index 8a80fba..e111e2e 100644 --- a/src/storybook.tsx +++ b/src/storybook.tsx @@ -3,7 +3,15 @@ import { Box, Text, render, useApp } from 'ink' import React from 'react' import { EnhancedSelectInput } from './enhanced-select-input/index.js' -type StorybookView = 'hotkeys' | 'custom-indicators' | 'custom-item' | undefined +type StorybookView = + | 'hotkeys' + | 'custom-indicators' + | 'custom-item' + | 'groups' + | 'multi-select' + | 'searchable' + | 'scroll-indicators' + | undefined export function Storybook() { const { exit } = useApp() @@ -69,50 +77,162 @@ export function Storybook() { { - if (item.value === 'hotkeys') { - setCurrentView('hotkeys') - } - - if (item.value === 'custom-indicators') { - setCurrentView('custom-indicators') + if (item.value === 'back') { + setOrientation(undefined) + return } - if (item.value === 'custom-item') { - setCurrentView('custom-item') - } + setCurrentView(item.value as StorybookView) + }} + /> + + )} + {currentView === 'groups' && orientation && ( + + Item Groups — items grouped under headers: + { if (item.value === 'back') { - setOrientation(undefined) + setCurrentView(undefined) } }} /> )} + {currentView === 'multi-select' && orientation && ( + + + Multi-Select — Space to toggle, Enter to confirm: + + { + console.log('Confirmed:', items.map((i) => i.label).join(', ')) + }} + onCancel={() => { + setCurrentView(undefined) + }} + /> + + )} + + {currentView === 'searchable' && orientation && ( + + Searchable — type to filter items: + { + console.log('Selected:', item.label) + }} + onCancel={() => { + setCurrentView(undefined) + }} + /> + + )} + + {currentView === 'scroll-indicators' && orientation && ( + + Scroll Indicators — limit=3 with ▲/▼ counts: + { + console.log('Selected:', item.label) + }} + onCancel={() => { + setCurrentView(undefined) + }} + /> + + )} + {currentView === 'custom-indicators' && orientation && ( Item Specific Custom Indicators View: @@ -145,14 +265,8 @@ export function Storybook() { onSelect={(item) => { if (item.value === 'back') { setCurrentView(undefined) - } - - if (item.value === 'hotkeys') { - setCurrentView('hotkeys') - } - - if (item.value === 'custom-item') { - setCurrentView('custom-item') + } else { + setCurrentView(item.value as StorybookView) } }} /> @@ -182,36 +296,35 @@ export function Storybook() { }} orientation={orientation} items={[ - { - label: 'View Hotkeys', - value: 'hotkeys', - }, - { - label: 'View Custom Indicators', - value: 'indicators', - }, + { label: 'View Hotkeys', value: 'hotkeys' }, + { label: 'View Custom Indicators', value: 'indicators' }, { label: 'View Custom Item Component', value: 'custom-item', disabled: true, }, - { - label: 'Go Back', - value: 'back', - hotkey: 'b', - }, + { label: 'Go Back', value: 'back', hotkey: 'b' }, ]} onSelect={(item) => { - if (item.value === 'back') { - setCurrentView(undefined) - } + switch (item.value) { + case 'back': { + setCurrentView(undefined) + break + } - if (item.value === 'hotkeys') { - setCurrentView('hotkeys') - } + case 'hotkeys': { + setCurrentView('hotkeys') + break + } - if (item.value === 'indicators') { - setCurrentView('custom-indicators') + case 'indicators': { + setCurrentView('custom-indicators') + break + } + + default: { + break + } } }} /> @@ -240,27 +353,13 @@ export function Storybook() { value: 'custom-item', hotkey: 'c', }, - { - label: 'Go Back', - value: 'back', - hotkey: 'b', - }, + { label: 'Go Back', value: 'back', hotkey: 'b' }, ]} onSelect={(item) => { if (item.value === 'back') { setCurrentView(undefined) - } - - if (item.value === 'hotkeys') { - setCurrentView('hotkeys') - } - - if (item.value === 'custom-indicators') { - setCurrentView('custom-indicators') - } - - if (item.value === 'custom-item') { - setCurrentView('custom-item') + } else { + setCurrentView(item.value as StorybookView) } }} /> From 61b0f5d213b619592158da68bc4cc571e5e3412f Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 16:28:24 -0400 Subject: [PATCH 4/5] fix: handle key.delete for backspace in searchable mode, add edge case tests The backspace handler only checked key.backspace, but terminals send \x7f (DEL) which Ink maps to key.delete, not key.backspace. Now checks both key.backspace and key.delete. New tests (5): - searchable + multiple: filter then confirm checked items - searchable + limit: navigation within paginated filtered results - searchable + isFocused=false: typing blocked - searchable: Home/End on filtered results - searchable: backspace progressively widens filter results --- src/enhanced-select-input/index.tsx | 4 +- src/test/enhanced-select-input.test.tsx | 185 ++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/enhanced-select-input/index.tsx b/src/enhanced-select-input/index.tsx index 13efa0d..c507175 100644 --- a/src/enhanced-select-input/index.tsx +++ b/src/enhanced-select-input/index.tsx @@ -287,8 +287,8 @@ export function useEnhancedSelectInput({ useInput( // eslint-disable-next-line complexity (input, key) => { - // In searchable mode, handle Backspace to remove last character - if (searchable && key.backspace) { + // In searchable mode, handle Backspace/Delete to remove last character + if (searchable && (key.backspace || key.delete)) { setSearchQuery((previous) => previous.slice(0, -1)) setSelectedIndex(0) if (limit) setRotateIndex(0) diff --git a/src/test/enhanced-select-input.test.tsx b/src/test/enhanced-select-input.test.tsx index 078a50f..ec3edd7 100644 --- a/src/test/enhanced-select-input.test.tsx +++ b/src/test/enhanced-select-input.test.tsx @@ -3290,3 +3290,188 @@ test('searchable: backspace on empty query does nothing', async (t) => { t.true(frame.includes('Banana')) t.true(frame.includes('/ Search...')) }) + +// --- Searchable + Multi-select combination --- + +test('searchable + multiple: can filter then confirm checked items', async (t) => { + const items = [ + { label: 'Apple', value: 'apple' }, + { label: 'Apricot', value: 'apricot' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, + ] + + let confirmed: string[] = [] + const { stdin } = render( + { + confirmed = selected.map((item) => String(item.value)) + }} + /> + ) + + await delay() + // Filter to only "ap" items, then confirm — should still include + // all previously checked items that match the filter + stdin.write('ap') + await delay() + stdin.write(ENTER) + await delay() + + // Only "apple" matches the filter AND is checked + t.is(confirmed.length, 1) + t.true(confirmed.includes('apple')) +}) + +// --- Searchable + limit + navigation --- + +test('searchable + limit: navigation works within paginated filtered results', async (t) => { + const items = [ + { label: 'Alpha', value: 'alpha' }, + { label: 'Apex', value: 'apex' }, + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, + ] + + let highlighted = '' + const { stdin } = render( + { + highlighted = item.label + }} + /> + ) + + await delay() + stdin.write('a') + await delay() + // "a" matches Alpha, Apex, Apple, Banana (all contain 'a') + t.is(highlighted, 'Alpha') + + stdin.write(ARROW_DOWN) + await delay() + t.is(highlighted, 'Apex') + + stdin.write(ARROW_DOWN) + await delay() + t.is(highlighted, 'Apple') + + // Should have scrolled past the limit=2 window + stdin.write(ARROW_DOWN) + await delay() + t.is(highlighted, 'Banana') +}) + +// --- Searchable + isFocused=false --- + +test('searchable: typing blocked when isFocused=false', async (t) => { + const items = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + ] + + let result: UseEnhancedSelectInputResult | undefined + const { stdin } = render( + { + result = r + }} + /> + ) + + await delay() + stdin.write('app') + await delay() + + // Query should remain empty since input is blocked + t.is(result?.searchQuery, '') + t.is(result?.visibleItems.length, 2) +}) + +// --- Searchable + Home/End on filtered results --- + +test('searchable: Home/End work on filtered results', async (t) => { + const items = [ + { label: 'Alpha', value: 'alpha' }, + { label: 'Apex', value: 'apex' }, + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + ] + + let highlighted = '' + const { stdin } = render( + { + highlighted = item.label + }} + /> + ) + + await delay() + t.is(highlighted, 'Alpha') + + stdin.write('ap') + await delay() + // After filtering, move down first to change selectedIndex + stdin.write(ARROW_DOWN) + await delay() + t.is(highlighted, 'Apple') + + stdin.write(HOME) + await delay() + t.is(highlighted, 'Apex') + + stdin.write(END) + await delay() + t.is(highlighted, 'Apple') +}) + +// --- Searchable: query with no results then backspace restores items --- + +test('searchable: multiple backspaces progressively restore items', async (t) => { + // This test verifies that backspace works to widen the filter. + // The existing "backspace removes last character" test covers single backspace. + // Here we verify the query display updates correctly. + const items = [ + { label: 'Apple', value: 'apple' }, + { label: 'Apricot', value: 'apricot' }, + { label: 'Banana', value: 'banana' }, + ] + + const { stdin, lastFrame } = render( + + ) + + await delay() + stdin.write('app') + await delay() + + let frame = lastFrame()! + t.true(frame.includes('/ app')) + t.true(frame.includes('Apple')) + t.false(frame.includes('Apricot')) + t.false(frame.includes('Banana')) + + // Single backspace to "ap" — now Apricot also matches + stdin.write('\u007F') + await delay() + + frame = lastFrame()! + t.true(frame.includes('/ ap')) + t.true(frame.includes('Apple')) + t.true(frame.includes('Apricot')) + t.false(frame.includes('Banana')) +}) From 28e897861fdb299934d2a6b8acdffa8bad4e5ba6 Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 16:29:34 -0400 Subject: [PATCH 5/5] docs: add blog post outline for 1.0.0 release --- blog-post-outline.md | 132 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 blog-post-outline.md diff --git a/blog-post-outline.md b/blog-post-outline.md new file mode 100644 index 0000000..9caa5b1 --- /dev/null +++ b/blog-post-outline.md @@ -0,0 +1,132 @@ +# Blog Post Outline: ink-enhanced-select-input 1.0.0 + +## Working Title + +"ink-enhanced-select-input hits 1.0 — a full-featured select component for terminal UIs" + +## Target Audience + +Developers building CLI tools with Ink/React who need richer selection UIs than the default `ink-select-input` provides. + +--- + +## Outline + +### 1. Intro / Hook + +- What the library is: an enhanced select input for Ink (React for CLIs) +- Why 1.0 now: the API is stable, battle-tested with 133 tests, and covers the use cases people actually need for production CLI tools +- Brief mention of the journey from 0.2.0 (basic component) to 1.0.0 (full-featured toolkit) + +### 2. What's New Since the Early Releases (the 1.0 feature set) + +#### Headless Hook: `useEnhancedSelectInput` + +- Extracted all behavior into a headless hook +- Consumers can build fully custom renderers while keeping navigation, pagination, hotkeys, and callbacks +- Returns `selectedIndex`, `visibleItems`, `itemsAbove`, `itemsBelow`, `checkedKeys`, `searchQuery` +- The component itself is now a thin rendering wrapper + +#### Multi-Select Mode + +- `multiple` prop enables checkbox-style selection +- Space toggles, Enter confirms +- `defaultSelectedKeys` for pre-populated state +- `onToggle` and `onConfirm` callbacks +- Hotkeys disabled in multi-select to avoid Space ambiguity + +#### Searchable / Filterable Mode + +- `searchable` prop enables inline type-to-filter +- Case-insensitive substring matching on labels +- Renders a `/ query` input line above the list +- Backspace to edit, Escape to clear (then cancel) +- Vim keys become search characters (no conflict) +- "No matches" empty state +- Works with groups, limit/pagination, and disabled items + +#### Item Groups with Section Headers + +- `group` field on items +- Visual headers rendered before each group's first item +- Non-navigable (purely visual) +- Custom `groupHeaderComponent` prop +- Works with pagination/limit + +#### Scroll Indicators + +- `showScrollIndicators` prop +- Shows `▲ N more` / `▼ N more` (or ◀/▶ in horizontal) +- Only appears when items are clipped by `limit` + +#### Escape / Cancel Support + +- `onCancel` prop fires on Escape +- In searchable mode: Escape clears query first, then fires onCancel +- Enables multi-step CLI "go back" flows without parent `useInput` hacks + +#### Home / End Keys + +- Jump to first/last enabled item +- Respects disabled items at boundaries +- Updates pagination window + +### 3. Quality & Reliability + +- 133 tests covering every feature, edge case, and interaction +- Strict TypeScript with generics (`Item`) +- Dev-only duplicate key warnings for object-valued items +- Items prop sync: selection re-validates when items change after mount +- Proper disabled item handling throughout (navigation, selection, hotkeys, Home/End) +- Bug found and fixed during 1.0 prep: backspace in searchable mode now handles both `key.backspace` (BS, \x08) and `key.delete` (DEL, \x7f) — terminals vary in which they send + +### 4. Architecture Decisions Worth Noting + +- Single file, co-located types — no separate type files +- Hook + component split means you can use just the behavior or the full UI +- ESM-only, Node 20+, React 19, Ink 6 +- No external dependencies beyond ink and react +- Modern `exports` field in package.json for proper ESM resolution +- Clean public API: only named exports (no default export ambiguity) + +### 5. Quick Start / Usage Example + +- Show a simple example +- Show a more complex example combining searchable + groups + limit + +### 6. What's Next / Call to Action + +- Link to GitHub repo +- Link to npm +- Mention it's MIT licensed +- Invite contributions / feedback + +--- + +## Key Stats to Mention + +- **133 tests** (up from ~10 in 0.2.0) +- **0 runtime dependencies** beyond ink + react +- **Generic value type** support (`Item`) +- **Headless hook** for custom renderers +- **6 interaction modes**: single-select, multi-select, searchable, grouped, paginated, horizontal + +## Features Changelog (0.2.0 → 1.0.0) + +| Version | Key Addition | +| ------- | --------------------------------------------------------------------- | +| 0.3.0 | Ink 6 / React 19 / Node 20 upgrade | +| 0.4.0 | Fixed initialIndex disabled skip, limit pagination, CI | +| 0.5.0 | Headless hook, scroll indicators, onCancel/Escape, Home/End | +| 0.6.0 | Multi-select mode, duplicate key warnings | +| 1.0.0 | Item groups, searchable mode, comprehensive test coverage, API polish | + +## Interesting Anecdote for the Post + +During the 1.0 prep audit, we discovered that the backspace handler in searchable mode only checked `key.backspace` — but most terminals send `\x7f` (DEL) for the backspace key, which Ink maps to `key.delete`. The fix was a one-line change (`key.backspace || key.delete`), but it was only caught because we wrote edge-case tests that exercised backspace in scenarios where the filter narrows results. Good reminder that thorough testing catches real bugs, not just theoretical ones. + +## Repo & Links + +- GitHub: https://github.com/gfargo/ink-enhanced-select-input +- npm: https://www.npmjs.com/package/ink-enhanced-select-input +- Ink: https://github.com/vadimdemedes/ink