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 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/src/enhanced-select-input/index.tsx b/src/enhanced-select-input/index.tsx index 99734c7..c507175 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 } @@ -294,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) @@ -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 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) } }} /> 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')) +}) 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.