@silvery/ui 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/package.json +71 -0
  2. package/src/animation/easing.ts +38 -0
  3. package/src/animation/index.ts +18 -0
  4. package/src/animation/useAnimation.ts +143 -0
  5. package/src/animation/useInterval.ts +39 -0
  6. package/src/animation/useLatest.ts +35 -0
  7. package/src/animation/useTimeout.ts +65 -0
  8. package/src/animation/useTransition.ts +110 -0
  9. package/src/animation.ts +24 -0
  10. package/src/ansi/index.ts +43 -0
  11. package/src/canvas/index.ts +169 -0
  12. package/src/cli/ansi.ts +85 -0
  13. package/src/cli/index.ts +39 -0
  14. package/src/cli/multi-progress.ts +340 -0
  15. package/src/cli/progress-bar.ts +222 -0
  16. package/src/cli/spinner.ts +275 -0
  17. package/src/components/Badge.tsx +54 -0
  18. package/src/components/Breadcrumb.tsx +72 -0
  19. package/src/components/Button.tsx +73 -0
  20. package/src/components/CommandPalette.tsx +186 -0
  21. package/src/components/Console.tsx +79 -0
  22. package/src/components/CursorLine.tsx +71 -0
  23. package/src/components/Divider.tsx +67 -0
  24. package/src/components/EditContextDisplay.tsx +164 -0
  25. package/src/components/ErrorBoundary.tsx +179 -0
  26. package/src/components/Form.tsx +86 -0
  27. package/src/components/GridCell.tsx +42 -0
  28. package/src/components/HorizontalVirtualList.tsx +375 -0
  29. package/src/components/ModalDialog.tsx +179 -0
  30. package/src/components/PickerDialog.tsx +208 -0
  31. package/src/components/PickerList.tsx +93 -0
  32. package/src/components/ProgressBar.tsx +126 -0
  33. package/src/components/Screen.tsx +78 -0
  34. package/src/components/ScrollbackList.tsx +92 -0
  35. package/src/components/ScrollbackView.tsx +390 -0
  36. package/src/components/SelectList.tsx +176 -0
  37. package/src/components/Skeleton.tsx +87 -0
  38. package/src/components/Spinner.tsx +64 -0
  39. package/src/components/SplitView.tsx +199 -0
  40. package/src/components/Table.tsx +139 -0
  41. package/src/components/Tabs.tsx +203 -0
  42. package/src/components/TextArea.tsx +264 -0
  43. package/src/components/TextInput.tsx +240 -0
  44. package/src/components/Toast.tsx +216 -0
  45. package/src/components/Toggle.tsx +73 -0
  46. package/src/components/Tooltip.tsx +60 -0
  47. package/src/components/TreeView.tsx +212 -0
  48. package/src/components/Typography.tsx +233 -0
  49. package/src/components/VirtualList.tsx +318 -0
  50. package/src/components/VirtualView.tsx +221 -0
  51. package/src/components/useReadline.ts +213 -0
  52. package/src/components/useTextArea.ts +648 -0
  53. package/src/components.ts +133 -0
  54. package/src/display/Table.tsx +179 -0
  55. package/src/display/index.ts +13 -0
  56. package/src/hooks/useTea.ts +133 -0
  57. package/src/image/Image.tsx +187 -0
  58. package/src/image/index.ts +15 -0
  59. package/src/image/kitty-graphics.ts +161 -0
  60. package/src/image/sixel-encoder.ts +194 -0
  61. package/src/images.ts +22 -0
  62. package/src/index.ts +34 -0
  63. package/src/input/Select.tsx +155 -0
  64. package/src/input/TextInput.tsx +227 -0
  65. package/src/input/index.ts +25 -0
  66. package/src/progress/als-context.ts +160 -0
  67. package/src/progress/declarative.ts +519 -0
  68. package/src/progress/index.ts +54 -0
  69. package/src/progress/step-node.ts +152 -0
  70. package/src/progress/steps.ts +425 -0
  71. package/src/progress/task.ts +138 -0
  72. package/src/progress/tasks.ts +216 -0
  73. package/src/react/ProgressBar.tsx +146 -0
  74. package/src/react/Spinner.tsx +74 -0
  75. package/src/react/Tasks.tsx +144 -0
  76. package/src/react/context.tsx +145 -0
  77. package/src/react/index.ts +30 -0
  78. package/src/types.ts +252 -0
  79. package/src/utils/eta.ts +155 -0
  80. package/src/utils/index.ts +13 -0
  81. package/src/wrappers/index.ts +36 -0
  82. package/src/wrappers/with-progress.ts +250 -0
  83. package/src/wrappers/with-select.ts +194 -0
  84. package/src/wrappers/with-spinner.ts +108 -0
  85. package/src/wrappers/with-text-input.ts +388 -0
  86. package/src/wrappers/wrap-emitter.ts +158 -0
  87. package/src/wrappers/wrap-generator.ts +143 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ScrollbackList - Declarative wrapper around useScrollback.
3
+ *
4
+ * Manages a list of items where completed items freeze into terminal
5
+ * scrollback. Items signal completion by calling `freeze()` from the
6
+ * useScrollbackItem hook. Frozen items are written to stdout in order
7
+ * and removed from the live render area.
8
+ *
9
+ * The component enforces a contiguous prefix invariant: items freeze
10
+ * in order from the start. If item 3 calls freeze() but items 0-2
11
+ * have not yet frozen, item 3 is marked but won't flush to scrollback
12
+ * until 0-2 are also frozen.
13
+ *
14
+ * This is a thin wrapper around ScrollbackView (which adds maxHistory support).
15
+ * The two components share identical scrollback semantics.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * function App() {
20
+ * const [tasks, setTasks] = useState<Task[]>(initialTasks)
21
+ *
22
+ * return (
23
+ * <ScrollbackList
24
+ * items={tasks}
25
+ * keyExtractor={(t) => t.id}
26
+ * footer={<Text>Status bar</Text>}
27
+ * >
28
+ * {(task) => <TaskItem task={task} />}
29
+ * </ScrollbackList>
30
+ * )
31
+ * }
32
+ *
33
+ * function TaskItem({ task }: { task: Task }) {
34
+ * const { freeze } = useScrollbackItem()
35
+ * useEffect(() => { if (task.done) freeze() }, [task.done])
36
+ * return <Text>{task.title}</Text>
37
+ * }
38
+ * ```
39
+ */
40
+
41
+ import type { ReactElement } from "react"
42
+ import type { ScrollbackMarkerCallbacks } from "@silvery/react/hooks/useScrollback"
43
+ import type { ReactNode } from "react"
44
+ import { ScrollbackView } from "./ScrollbackView"
45
+
46
+ // ============================================================================
47
+ // Types
48
+ // ============================================================================
49
+
50
+ export interface ScrollbackListProps<T> {
51
+ /** Array of items to render. */
52
+ items: T[]
53
+ /** Render function for each item. Receives item and its index. */
54
+ children?: (item: T, index: number) => ReactNode
55
+ /** Render function for each item. Alternative to children — prefer this for performance
56
+ * as it can be wrapped in useCallback for memoization. */
57
+ renderItem?: (item: T, index: number) => ReactNode
58
+ /** Extract a unique key for each item. */
59
+ keyExtractor: (item: T, index: number) => string | number
60
+ /**
61
+ * Data-driven frozen predicate. Items matching this predicate are frozen
62
+ * immediately on render (no effect roundtrip needed). Works in addition
63
+ * to the freeze() callback from useScrollbackItem.
64
+ */
65
+ isFrozen?: (item: T, index: number) => boolean
66
+ /** Optional footer pinned at the bottom of the terminal. */
67
+ footer?: ReactNode
68
+ /** @deprecated Footer now auto-sizes to content. This prop is ignored. */
69
+ footerHeight?: number
70
+ /** OSC 133 marker configuration, forwarded to useScrollback. */
71
+ markers?: boolean | ScrollbackMarkerCallbacks<T>
72
+ /** Terminal width in columns. Default: process.stdout.columns. */
73
+ width?: number
74
+ /** Output stream for writing frozen items. Default: process.stdout. */
75
+ stdout?: { write(data: string): boolean }
76
+ /** Called when recovery from inconsistent state occurs. */
77
+ onRecovery?: () => void
78
+ }
79
+
80
+ // ============================================================================
81
+ // Component
82
+ // ============================================================================
83
+
84
+ /**
85
+ * A list component that pushes completed items to terminal scrollback.
86
+ *
87
+ * Thin wrapper around ScrollbackView — delegates all rendering and scrollback
88
+ * management to ScrollbackView without maxHistory (unlimited by default).
89
+ */
90
+ export function ScrollbackList<T>(props: ScrollbackListProps<T>): ReactElement {
91
+ return <ScrollbackView {...props} />
92
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * ScrollbackView - Native scrollback root component.
3
+ *
4
+ * Uses the normal terminal buffer. Children flow vertically. As items scroll
5
+ * off the top of the screen, they transition through the virtualization
6
+ * lifecycle (Live → Virtualized → Static) and are committed to terminal
7
+ * scrollback.
8
+ *
9
+ * The user scrolls with their terminal's native scroll (mouse wheel, scrollbar,
10
+ * Shift+PageUp). Text selection is free. Content becomes part of the terminal's
11
+ * permanent history.
12
+ *
13
+ * This is an evolution of ScrollbackList with automatic lifecycle management
14
+ * via the shared useVirtualizer() engine.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <ScrollbackView footer={<StatusBar />}>
19
+ * {messages.map(m => <Message key={m.id} data={m} />)}
20
+ * </ScrollbackView>
21
+ * ```
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * // With item-level lifecycle control via useScrollbackItem
26
+ * <ScrollbackView
27
+ * items={tasks}
28
+ * keyExtractor={(t) => t.id}
29
+ * isFrozen={(t) => t.done}
30
+ * footer={<Text>Status bar</Text>}
31
+ * >
32
+ * {(task) => <TaskItem task={task} />}
33
+ * </ScrollbackView>
34
+ * ```
35
+ */
36
+
37
+ import {
38
+ memo,
39
+ useCallback,
40
+ useEffect,
41
+ useLayoutEffect,
42
+ useMemo,
43
+ useRef,
44
+ useState,
45
+ type ReactElement,
46
+ type ReactNode,
47
+ } from "react"
48
+ import type { ScrollbackMarkerCallbacks } from "@silvery/react/hooks/useScrollback"
49
+ import { useScrollback } from "@silvery/react/hooks/useScrollback"
50
+ import { renderStringSync } from "@silvery/react/render-string"
51
+ import { ScrollbackItemProvider } from "@silvery/react/hooks/useScrollbackItem"
52
+ import type { TeaNode } from "@silvery/tea/types"
53
+
54
+ // ============================================================================
55
+ // Types
56
+ // ============================================================================
57
+
58
+ export interface ScrollbackViewProps<T> {
59
+ /** Array of items to render. */
60
+ items: T[]
61
+ /** Render function for each item. Receives item and its index. */
62
+ children?: (item: T, index: number) => ReactNode
63
+ /** Render function for each item. Alternative to children — prefer this for performance
64
+ * as it can be wrapped in useCallback for memoization. */
65
+ renderItem?: (item: T, index: number) => ReactNode
66
+ /** Extract a unique key for each item. */
67
+ keyExtractor: (item: T, index: number) => string | number
68
+ /**
69
+ * Data-driven frozen predicate. Items matching this predicate are frozen
70
+ * immediately on render (no effect roundtrip needed). Works in addition
71
+ * to the freeze() callback from useScrollbackItem.
72
+ */
73
+ isFrozen?: (item: T, index: number) => boolean
74
+ /** Optional footer pinned at the bottom of the terminal. */
75
+ footer?: ReactNode
76
+ /** @deprecated Footer now auto-sizes to content. This prop is ignored. */
77
+ footerHeight?: number
78
+ /**
79
+ * Maximum lines to retain in dynamic scrollback before promoting to static.
80
+ * Items beyond this boundary become static (data dropped, terminal owns them).
81
+ * Default: 10000
82
+ */
83
+ maxHistory?: number
84
+ /** OSC 133 marker configuration, forwarded to useScrollback. */
85
+ markers?: boolean | ScrollbackMarkerCallbacks<T>
86
+ /** Terminal width in columns. Default: process.stdout.columns. */
87
+ width?: number
88
+ /** Output stream for writing frozen items. Default: process.stdout. */
89
+ stdout?: { write(data: string): boolean }
90
+ /** Called when recovery from inconsistent state occurs. */
91
+ onRecovery?: () => void
92
+ }
93
+
94
+ // ============================================================================
95
+ // Helpers
96
+ // ============================================================================
97
+
98
+ /** Get terminal columns, falling back to 80 for non-TTY environments. */
99
+ function getTermCols(): number {
100
+ return process.stdout.columns ?? 80
101
+ }
102
+
103
+ // ============================================================================
104
+ // Component
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Native scrollback view with automatic item lifecycle management.
109
+ *
110
+ * Items rendered inside ScrollbackView have access to `useScrollbackItem()`
111
+ * which provides a `freeze()` function. When an item calls freeze(), it is
112
+ * marked for scrollback. Once a contiguous prefix of items are all frozen,
113
+ * they are rendered to strings and written to stdout via useScrollback.
114
+ *
115
+ * This is the native-scrollback counterpart to VirtualView. Where
116
+ * VirtualView keeps everything in the React tree, ScrollbackView commits
117
+ * completed items to the terminal's scrollback buffer.
118
+ *
119
+ * NOTE: DO NOT use DECSTBM scroll regions to pin the footer. Lines scrolled
120
+ * out of a DECSTBM region are DISCARDED by the terminal — they never enter
121
+ * scrollback history. This has been confirmed across multiple terminals
122
+ * (xterm, iTerm2, Ghostty, etc.) and is a fundamental terminal limitation.
123
+ * The footer is pinned purely via flex layout (flexShrink={0}).
124
+ */
125
+ export function ScrollbackView<T>({
126
+ items,
127
+ children,
128
+ renderItem,
129
+ keyExtractor,
130
+ isFrozen: isFrozenProp,
131
+ footer,
132
+ footerHeight: _footerHeight,
133
+ maxHistory: _maxHistory = 10000,
134
+ markers,
135
+ width,
136
+ stdout = process.stdout as unknown as { write(data: string): boolean },
137
+ onRecovery,
138
+ }: ScrollbackViewProps<T>): ReactElement {
139
+ // Track terminal width reactively so we re-render on resize.
140
+ // Without this, getTermCols() is only called during render — if no React
141
+ // state changes on resize, the component never re-renders and useScrollback
142
+ // never detects the width change (frozen items aren't re-emitted).
143
+ const [termWidth, setTermWidth] = useState(getTermCols)
144
+ useEffect(() => {
145
+ if (width !== undefined) return // Parent controls width — skip listener
146
+ // Use the stdout prop (defaults to process.stdout) — works with both
147
+ // real terminals and test mocks that emit "resize" events.
148
+ const stream = stdout as { on?: Function; off?: Function; columns?: number }
149
+ if (!stream?.on || !stream?.columns) return
150
+ const onResize = () => setTermWidth((stream as { columns: number }).columns ?? 80)
151
+ stream.on("resize", onResize)
152
+ return () => {
153
+ stream.off?.("resize", onResize)
154
+ }
155
+ }, [width, stdout])
156
+
157
+ const effectiveWidth = width ?? termWidth
158
+
159
+ // Track the outer node's layout to derive horizontal padding.
160
+ //
161
+ // When the component is inside a parent with padding/borders, the layout
162
+ // engine gives a narrower width. Frozen items must be rendered at this
163
+ // narrower width to match live items.
164
+ //
165
+ // Key insight: horizontal padding (in columns) is STABLE across resize.
166
+ // paddingX=1 always means 2 columns of padding whether the terminal is
167
+ // 80 or 60 cols wide. So we store padding as a stable offset and compute
168
+ // frozenWidth = effectiveWidth - hPadding on every render. This avoids
169
+ // the stale-layoutInfo problem where resize triggers a re-emit before
170
+ // the layout engine has recomputed at the new width.
171
+ const outerNodeRef = useRef<TeaNode | null>(null)
172
+ const [layoutInfo, setLayoutInfo] = useState<{ width: number; x: number } | null>(null)
173
+
174
+ // Horizontal padding: total left+right padding from parent containers.
175
+ // Updated only when layoutInfo changes (at which point effectiveWidth and
176
+ // layoutInfo.width are consistent — both computed at the same terminal width).
177
+ const hPaddingRef = useRef(0)
178
+ const prevLayoutInfoRef = useRef<{ width: number; x: number } | null>(null)
179
+
180
+ useLayoutEffect(() => {
181
+ const node = outerNodeRef.current
182
+ if (!node) return
183
+
184
+ const update = () => {
185
+ const rect = node.contentRect
186
+ if (rect && rect.width > 0) {
187
+ setLayoutInfo((prev) => {
188
+ if (prev && prev.width === rect.width && prev.x === rect.x) return prev
189
+ return { width: rect.width, x: rect.x }
190
+ })
191
+ }
192
+ }
193
+
194
+ update()
195
+ node.layoutSubscribers.add(update)
196
+ return () => {
197
+ node.layoutSubscribers.delete(update)
198
+ }
199
+ }, [])
200
+
201
+ // Update hPadding only when layoutInfo changes (not on every render).
202
+ // When layoutInfo changes, the layout engine just ran, so effectiveWidth
203
+ // and layoutInfo.width are consistent — safe to compute the delta.
204
+ if (layoutInfo !== prevLayoutInfoRef.current) {
205
+ prevLayoutInfoRef.current = layoutInfo
206
+ if (layoutInfo && layoutInfo.width > 0 && width === undefined) {
207
+ const padding = effectiveWidth - layoutInfo.width
208
+ if (padding >= 0) hPaddingRef.current = padding
209
+ }
210
+ }
211
+
212
+ // Frozen rendering width: terminal width minus stable horizontal padding.
213
+ // This is correct even during resize (before layout recomputes) because
214
+ // hPadding is stable — it was computed from the previous layout and doesn't
215
+ // change when the terminal resizes.
216
+ const frozenWidth = width ?? Math.max(1, effectiveWidth - hPaddingRef.current)
217
+ const frozenLeftPad = layoutInfo?.x ?? 0
218
+
219
+ // Resolve render function: renderItem takes precedence over children
220
+ const render = renderItem ?? children
221
+ if (!render) {
222
+ throw new Error("ScrollbackView requires either a `renderItem` prop or `children` render function")
223
+ }
224
+
225
+ // Set of item keys that have been marked as frozen via freeze()
226
+ const [frozenKeys, setFrozenKeys] = useState<Set<string | number>>(() => new Set())
227
+
228
+ // Optional snapshot overrides: key -> ReactElement
229
+ const snapshotRef = useRef<Map<string | number, ReactElement>>(new Map())
230
+
231
+ // Cached freeze functions per key — stable references for memoization
232
+ const freezeCache = useRef(new Map<string | number, (snapshot?: ReactElement) => void>())
233
+
234
+ const getFreeze = useCallback((key: string | number) => {
235
+ let fn = freezeCache.current.get(key)
236
+ if (!fn) {
237
+ fn = (snapshot?: ReactElement) => {
238
+ if (snapshot) snapshotRef.current.set(key, snapshot)
239
+ setFrozenKeys((prev) => {
240
+ if (prev.has(key)) return prev
241
+ const next = new Set(prev)
242
+ next.add(key)
243
+ return next
244
+ })
245
+ }
246
+ freezeCache.current.set(key, fn)
247
+ }
248
+ return fn
249
+ }, [])
250
+
251
+ // Frozen predicate for useScrollback: combine data-driven isFrozen prop
252
+ // with the imperative freeze() callback (frozenKeys set).
253
+ const frozenPredicate = useCallback(
254
+ (item: T, index: number): boolean => {
255
+ if (isFrozenProp?.(item, index)) return true
256
+ const key = keyExtractor(item, index)
257
+ return frozenKeys.has(key)
258
+ },
259
+ [frozenKeys, keyExtractor, isFrozenProp],
260
+ )
261
+
262
+ // Render callback for useScrollback: render frozen item to string.
263
+ // Uses frozenWidth (layout-aware) instead of effectiveWidth (terminal-based)
264
+ // to match the width that live items get from the layout engine.
265
+ // Prepends left-padding to align frozen output with the parent's position.
266
+ const renderFrozen = useCallback(
267
+ (item: T, index: number): string => {
268
+ const key = keyExtractor(item, index)
269
+ const snapshot = snapshotRef.current.get(key)
270
+ const noop = () => {}
271
+ const inner = snapshot ?? (render(item, index) as ReactElement)
272
+ const element = (
273
+ <ScrollbackItemProvider freeze={noop} isFrozen={true} index={index} nearScrollback={false}>
274
+ {inner}
275
+ </ScrollbackItemProvider>
276
+ )
277
+ try {
278
+ let text = renderStringSync(element, { width: frozenWidth, plain: false })
279
+ // Add left-padding to match the parent's layout position.
280
+ // Without this, frozen items start at column 0 while live items
281
+ // are indented by the parent's padding.
282
+ if (frozenLeftPad > 0) {
283
+ const pad = " ".repeat(frozenLeftPad)
284
+ text = text
285
+ .split("\n")
286
+ .map((line) => pad + line)
287
+ .join("\n")
288
+ }
289
+ return text
290
+ } catch {
291
+ return `[frozen item ${index}]`
292
+ }
293
+ },
294
+ [render, keyExtractor, frozenWidth, frozenLeftPad],
295
+ )
296
+
297
+ // Use the underlying useScrollback hook to manage stdout writes
298
+ const frozenCount = useScrollback(items, {
299
+ frozen: frozenPredicate,
300
+ render: renderFrozen,
301
+ stdout,
302
+ markers,
303
+ width: effectiveWidth,
304
+ })
305
+
306
+ // Clean up snapshot refs for items that have been flushed to scrollback
307
+ useEffect(() => {
308
+ if (frozenCount > 0) {
309
+ for (let i = 0; i < frozenCount; i++) {
310
+ const key = keyExtractor(items[i]!, i)
311
+ snapshotRef.current.delete(key)
312
+ }
313
+ }
314
+ }, [frozenCount, items, keyExtractor])
315
+
316
+ // Recovery: detect if frozen keys reference items no longer in the list
317
+ useEffect(() => {
318
+ if (frozenKeys.size === 0) return
319
+ const currentKeys = new Set(items.map((item, i) => keyExtractor(item, i)))
320
+ let hasStale = false
321
+ for (const key of frozenKeys) {
322
+ if (!currentKeys.has(key)) {
323
+ hasStale = true
324
+ break
325
+ }
326
+ }
327
+ if (hasStale) {
328
+ setFrozenKeys((prev) => {
329
+ const next = new Set<string | number>()
330
+ for (const key of prev) {
331
+ if (currentKeys.has(key)) next.add(key)
332
+ }
333
+ return next
334
+ })
335
+ // Clean up stale freeze cache entries
336
+ for (const key of freezeCache.current.keys()) {
337
+ if (!currentKeys.has(key)) freezeCache.current.delete(key)
338
+ }
339
+ onRecovery?.()
340
+ }
341
+ }, [items, keyExtractor, frozenKeys, onRecovery])
342
+
343
+ // Build live (non-frozen) items
344
+ const liveItems = useMemo(() => {
345
+ const result: Array<{ item: T; index: number; key: string | number }> = []
346
+ for (let i = frozenCount; i < items.length; i++) {
347
+ const key = keyExtractor(items[i]!, i)
348
+ result.push({ item: items[i]!, index: i, key })
349
+ }
350
+ return result
351
+ }, [items, frozenCount, keyExtractor])
352
+
353
+ // Render live items with memoized wrappers
354
+ return (
355
+ <silvery-box ref={outerNodeRef} flexDirection="column" flexGrow={1}>
356
+ {/* Content area: live (unfrozen) items, grows to push footer to bottom */}
357
+ <silvery-box flexDirection="column" flexGrow={1}>
358
+ {liveItems.map(({ item, index, key }) => (
359
+ <MemoItem key={key} item={item} index={index} freeze={getFreeze(key)} renderFn={render} />
360
+ ))}
361
+ </silvery-box>
362
+
363
+ {/* Footer pinned at bottom — auto-sizes to content */}
364
+ {footer != null && (
365
+ <silvery-box flexDirection="column" flexShrink={0}>
366
+ {footer}
367
+ </silvery-box>
368
+ )}
369
+ </silvery-box>
370
+ )
371
+ }
372
+
373
+ // ============================================================================
374
+ // MemoItem — skips reconciliation when item/index/freeze/renderFn are stable
375
+ // ============================================================================
376
+
377
+ interface MemoItemProps<T> {
378
+ item: T
379
+ index: number
380
+ freeze: (snapshot?: ReactElement) => void
381
+ renderFn: (item: T, index: number) => ReactNode
382
+ }
383
+
384
+ const MemoItem = memo(function MemoItem<T>({ item, index, freeze, renderFn }: MemoItemProps<T>) {
385
+ return (
386
+ <ScrollbackItemProvider freeze={freeze} isFrozen={false} index={index} nearScrollback={false}>
387
+ {renderFn(item, index)}
388
+ </ScrollbackItemProvider>
389
+ )
390
+ }) as <T>(props: MemoItemProps<T> & { key?: React.Key }) => ReactElement
@@ -0,0 +1,176 @@
1
+ /**
2
+ * SelectList Component
3
+ *
4
+ * A keyboard-navigable single-select list. Supports controlled and uncontrolled modes.
5
+ *
6
+ * Usage:
7
+ * ```tsx
8
+ * const items = [
9
+ * { label: "Apple", value: "apple" },
10
+ * { label: "Banana", value: "banana" },
11
+ * { label: "Cherry", value: "cherry", disabled: true },
12
+ * ]
13
+ *
14
+ * <SelectList items={items} onSelect={(opt) => console.log(opt.value)} />
15
+ * ```
16
+ */
17
+ import React, { useCallback, useState } from "react"
18
+ import { useInput } from "@silvery/react/hooks/useInput"
19
+ import { Box } from "@silvery/react/components/Box"
20
+ import { Text } from "@silvery/react/components/Text"
21
+
22
+ // =============================================================================
23
+ // Types
24
+ // =============================================================================
25
+
26
+ export interface SelectOption {
27
+ label: string
28
+ value: string
29
+ disabled?: boolean
30
+ }
31
+
32
+ export interface SelectListProps {
33
+ /** List of options */
34
+ items: SelectOption[]
35
+ /** Controlled: current highlighted index */
36
+ highlightedIndex?: number
37
+ /** Called when highlight changes (controlled mode) */
38
+ onHighlight?: (index: number) => void
39
+ /** Called when user confirms selection (Enter) */
40
+ onSelect?: (option: SelectOption, index: number) => void
41
+ /** Initial index for uncontrolled mode */
42
+ initialIndex?: number
43
+ /** Max visible items (rest scrolled) */
44
+ maxVisible?: number
45
+ /** Whether this list captures input (default: true) */
46
+ isActive?: boolean
47
+ }
48
+
49
+ // =============================================================================
50
+ // Helpers
51
+ // =============================================================================
52
+
53
+ function findNextEnabled(items: SelectOption[], current: number, direction: 1 | -1): number {
54
+ const len = items.length
55
+ if (len === 0) return current
56
+
57
+ let next = current + direction
58
+ for (let i = 0; i < len; i++) {
59
+ if (next < 0) next = len - 1
60
+ if (next >= len) next = 0
61
+ if (!items[next]!.disabled) return next
62
+ next += direction
63
+ }
64
+
65
+ // All items disabled; stay put
66
+ return current
67
+ }
68
+
69
+ function findFirstEnabled(items: SelectOption[]): number {
70
+ for (let i = 0; i < items.length; i++) {
71
+ if (!items[i]!.disabled) return i
72
+ }
73
+ return 0
74
+ }
75
+
76
+ function findLastEnabled(items: SelectOption[]): number {
77
+ for (let i = items.length - 1; i >= 0; i--) {
78
+ if (!items[i]!.disabled) return i
79
+ }
80
+ return 0
81
+ }
82
+
83
+ // =============================================================================
84
+ // Component
85
+ // =============================================================================
86
+
87
+ export function SelectList({
88
+ items,
89
+ highlightedIndex: controlledIndex,
90
+ onHighlight,
91
+ onSelect,
92
+ initialIndex,
93
+ maxVisible,
94
+ isActive = true,
95
+ }: SelectListProps): React.ReactElement {
96
+ const isControlled = controlledIndex !== undefined
97
+
98
+ const [uncontrolledIndex, setUncontrolledIndex] = useState(initialIndex ?? findFirstEnabled(items))
99
+
100
+ const currentIndex = isControlled ? controlledIndex : uncontrolledIndex
101
+
102
+ const setIndex = useCallback(
103
+ (index: number) => {
104
+ if (!isControlled) {
105
+ setUncontrolledIndex(index)
106
+ }
107
+ onHighlight?.(index)
108
+ },
109
+ [isControlled, onHighlight],
110
+ )
111
+
112
+ useInput(
113
+ (input, key) => {
114
+ if (items.length === 0) return
115
+
116
+ if (key.upArrow || input === "k") {
117
+ setIndex(findNextEnabled(items, currentIndex, -1))
118
+ return
119
+ }
120
+
121
+ if (key.downArrow || input === "j") {
122
+ setIndex(findNextEnabled(items, currentIndex, 1))
123
+ return
124
+ }
125
+
126
+ if (key.return) {
127
+ const item = items[currentIndex]
128
+ if (item && !item.disabled) {
129
+ onSelect?.(item, currentIndex)
130
+ }
131
+ return
132
+ }
133
+
134
+ // Home: Ctrl+A
135
+ if (key.ctrl && input === "a") {
136
+ setIndex(findFirstEnabled(items))
137
+ return
138
+ }
139
+
140
+ // End: Ctrl+E
141
+ if (key.ctrl && input === "e") {
142
+ setIndex(findLastEnabled(items))
143
+ return
144
+ }
145
+ },
146
+ { isActive },
147
+ )
148
+
149
+ // Compute visible window
150
+ const showAll = !maxVisible || items.length <= maxVisible
151
+ let startIdx = 0
152
+ let visibleItems = items
153
+
154
+ if (!showAll) {
155
+ // Center the highlighted item in the visible window
156
+ const half = Math.floor(maxVisible / 2)
157
+ startIdx = Math.max(0, Math.min(currentIndex - half, items.length - maxVisible))
158
+ visibleItems = items.slice(startIdx, startIdx + maxVisible)
159
+ }
160
+
161
+ return (
162
+ <Box flexDirection="column">
163
+ {visibleItems.map((item, i) => {
164
+ const actualIndex = showAll ? i : startIdx + i
165
+ const isHighlighted = actualIndex === currentIndex
166
+
167
+ return (
168
+ <Text key={item.value} inverse={isHighlighted} dimColor={item.disabled}>
169
+ {isHighlighted ? "▸ " : " "}
170
+ {item.label}
171
+ </Text>
172
+ )
173
+ })}
174
+ </Box>
175
+ )
176
+ }