@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,233 @@
1
+ /**
2
+ * Typography Preset Components
3
+ *
4
+ * Semantic text hierarchy for TUIs. Since terminals can't vary font size,
5
+ * these presets use color + bold/dim/italic to create clear visual levels.
6
+ *
7
+ * All components accept an optional `color` prop to override the default.
8
+ * Headings default to semantic theme colors; pass a custom color for
9
+ * panel differentiation (e.g., <H1 color="$success">Panel A</H1>).
10
+ *
11
+ * Lists support nesting via UL/OL containers:
12
+ * ```tsx
13
+ * <UL>
14
+ * <LI>First item</LI>
15
+ * <LI>Second item
16
+ * <UL>
17
+ * <LI>Nested bullet</LI>
18
+ * </UL>
19
+ * </LI>
20
+ * </UL>
21
+ * ```
22
+ */
23
+ import type { ReactNode } from "react"
24
+ import { createContext, useContext, Children, cloneElement, isValidElement } from "react"
25
+ import { Box } from "@silvery/react/components/Box"
26
+ import { Text } from "@silvery/react/components/Text"
27
+
28
+ export interface TypographyProps {
29
+ children?: ReactNode
30
+ color?: string
31
+ }
32
+
33
+ // ============================================================================
34
+ // Headings
35
+ // ============================================================================
36
+
37
+ /** Page title — $primary + bold. Maximum emphasis. */
38
+ export function H1({ children, color }: TypographyProps) {
39
+ return (
40
+ <Text bold color={color ?? "$primary"}>
41
+ {children}
42
+ </Text>
43
+ )
44
+ }
45
+
46
+ /** Section heading — $accent + bold. Contrasts with H1. */
47
+ export function H2({ children, color }: TypographyProps) {
48
+ return (
49
+ <Text bold color={color ?? "$accent"}>
50
+ {children}
51
+ </Text>
52
+ )
53
+ }
54
+
55
+ /** Group heading — bold only. Stands out without accent color. */
56
+ export function H3({ children, color }: TypographyProps) {
57
+ return (
58
+ <Text bold color={color}>
59
+ {children}
60
+ </Text>
61
+ )
62
+ }
63
+
64
+ // ============================================================================
65
+ // Body Text
66
+ // ============================================================================
67
+
68
+ /** Paragraph — plain body text. Semantic wrapper for readability. */
69
+ export function P({ children, color }: TypographyProps) {
70
+ return <Text color={color}>{children}</Text>
71
+ }
72
+
73
+ /** Introductory/lead text — $muted + italic. Slightly elevated, slightly receded. */
74
+ export function Lead({ children, color }: TypographyProps) {
75
+ return (
76
+ <Text italic color={color ?? "$muted"}>
77
+ {children}
78
+ </Text>
79
+ )
80
+ }
81
+
82
+ /** Secondary/supporting text — $muted. Recedes from body text. */
83
+ export function Muted({ children, color }: TypographyProps) {
84
+ return <Text color={color ?? "$muted"}>{children}</Text>
85
+ }
86
+
87
+ /** Fine print — $muted + dim. Captions, footnotes, text that recedes even more than Muted. */
88
+ export function Small({ children, color }: TypographyProps) {
89
+ return (
90
+ <Text dimColor color={color ?? "$muted"}>
91
+ {children}
92
+ </Text>
93
+ )
94
+ }
95
+
96
+ /** Bold emphasis — inline strong text. */
97
+ export function Strong({ children, color }: TypographyProps) {
98
+ return (
99
+ <Text bold color={color}>
100
+ {children}
101
+ </Text>
102
+ )
103
+ }
104
+
105
+ /** Italic emphasis — inline emphasized text. */
106
+ export function Em({ children, color }: TypographyProps) {
107
+ return (
108
+ <Text italic color={color}>
109
+ {children}
110
+ </Text>
111
+ )
112
+ }
113
+
114
+ // ============================================================================
115
+ // Inline Elements
116
+ // ============================================================================
117
+
118
+ /** Inline code — $mutedbg background with padding. */
119
+ export function Code({ children, color }: TypographyProps) {
120
+ return (
121
+ <Text backgroundColor="$mutedbg" color={color}>
122
+ {` ${children} `}
123
+ </Text>
124
+ )
125
+ }
126
+
127
+ /** Keyboard shortcut badge — $mutedbg background + bold. */
128
+ export function Kbd({ children, color }: TypographyProps) {
129
+ return (
130
+ <Text backgroundColor="$mutedbg" bold color={color}>
131
+ {` ${children} `}
132
+ </Text>
133
+ )
134
+ }
135
+
136
+ // ============================================================================
137
+ // Block Elements
138
+ // ============================================================================
139
+
140
+ /** Blockquote — │ border in $muted + italic content. Wrapped text stays indented. */
141
+ export function Blockquote({ children, color }: TypographyProps) {
142
+ return (
143
+ <Box>
144
+ <Text color={color ?? "$muted"}>│ </Text>
145
+ <Box flexShrink={1}>
146
+ <Text italic>{children}</Text>
147
+ </Box>
148
+ </Box>
149
+ )
150
+ }
151
+
152
+ /** Code block — │ border in $border + monospace content. Distinct from Blockquote. */
153
+ export function CodeBlock({ children, color }: TypographyProps) {
154
+ return (
155
+ <Box>
156
+ <Text color={color ?? "$border"}>│ </Text>
157
+ <Box flexShrink={1}>
158
+ <Text>{children}</Text>
159
+ </Box>
160
+ </Box>
161
+ )
162
+ }
163
+
164
+ /** Horizontal rule — thin line across the available width. */
165
+ export function HR({ color }: { color?: string }) {
166
+ return (
167
+ <Text color={color ?? "$border"} wrap="truncate">
168
+ {"─".repeat(200)}
169
+ </Text>
170
+ )
171
+ }
172
+
173
+ // ============================================================================
174
+ // Lists
175
+ // ============================================================================
176
+
177
+ interface ListContextValue {
178
+ level: number
179
+ ordered: boolean
180
+ }
181
+
182
+ const ListContext = createContext<ListContextValue>({ level: 0, ordered: false })
183
+
184
+ /** Unordered list container. Nest inside another UL/OL for indented sub-lists. */
185
+ export function UL({ children }: TypographyProps) {
186
+ const parent = useContext(ListContext)
187
+ return (
188
+ <ListContext.Provider value={{ level: parent.level + 1, ordered: false }}>
189
+ <Box flexDirection="column">{children}</Box>
190
+ </ListContext.Provider>
191
+ )
192
+ }
193
+
194
+ /** Ordered list container. Auto-numbers LI children. Nest for sub-lists. */
195
+ export function OL({ children }: TypographyProps) {
196
+ const parent = useContext(ListContext)
197
+ let index = 0
198
+ const numbered = Children.map(children, (child) => {
199
+ if (isValidElement(child) && child.type === LI) {
200
+ index++
201
+ return cloneElement(child as React.ReactElement<{ _index?: number }>, { _index: index })
202
+ }
203
+ return child
204
+ })
205
+ return (
206
+ <ListContext.Provider value={{ level: parent.level + 1, ordered: true }}>
207
+ <Box flexDirection="column">{numbered}</Box>
208
+ </ListContext.Provider>
209
+ )
210
+ }
211
+
212
+ const BULLETS = ["•", "◦", "▸", "-"]
213
+
214
+ /** List item with hanging indent. Use inside UL or OL. 2-char marker (bullet + space). */
215
+ export function LI({ children, color, _index }: TypographyProps & { _index?: number }) {
216
+ const { level, ordered } = useContext(ListContext)
217
+ const effectiveLevel = Math.max(level, 1)
218
+ const indent = " ".repeat(effectiveLevel - 1)
219
+ const bullet = BULLETS[Math.min(effectiveLevel - 1, BULLETS.length - 1)]
220
+ const marker = ordered && _index != null ? `${_index}. ` : `${bullet} `
221
+
222
+ return (
223
+ <Box>
224
+ <Text color={color ?? "$muted"}>
225
+ {indent}
226
+ {marker}
227
+ </Text>
228
+ <Box flexShrink={1}>
229
+ <Text color={color}>{children}</Text>
230
+ </Box>
231
+ </Box>
232
+ )
233
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * VirtualList Component
3
+ *
4
+ * React-level virtualization for long lists. Only renders items within the
5
+ * visible viewport plus overscan, using placeholder boxes for virtual height.
6
+ *
7
+ * Thin wrapper around VirtualView that adds:
8
+ * - Interactive mode: keyboard navigation (j/k, arrows, PgUp/PgDn, Home/End, G), mouse wheel, selection state
9
+ * - Virtualized prefix: `virtualized` prop for contiguous prefix exclusion
10
+ * - ItemMeta: Third arg to renderItem with `{ isSelected }`
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * // Declarative (parent controls scroll position)
15
+ * <VirtualList
16
+ * items={cards}
17
+ * height={20}
18
+ * itemHeight={1}
19
+ * scrollTo={selectedIndex}
20
+ * renderItem={(card, index) => (
21
+ * <TreeCard key={card.id} card={card} isSelected={index === selected} />
22
+ * )}
23
+ * />
24
+ *
25
+ * // Interactive (built-in j/k, arrows, PgUp/PgDn, Home/End, G, mouse wheel)
26
+ * <VirtualList
27
+ * items={items}
28
+ * height={20}
29
+ * itemHeight={1}
30
+ * interactive
31
+ * onSelect={(index) => openItem(items[index])}
32
+ * renderItem={(item, index, meta) => (
33
+ * <Text>{meta?.isSelected ? '> ' : ' '}{item.name}</Text>
34
+ * )}
35
+ * />
36
+ * ```
37
+ */
38
+ import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react"
39
+ import { useInput } from "@silvery/react/hooks/useInput"
40
+ import { VirtualView } from "./VirtualView"
41
+ import type { VirtualViewHandle } from "./VirtualView"
42
+
43
+ // =============================================================================
44
+ // Types
45
+ // =============================================================================
46
+
47
+ /** Metadata passed to renderItem in the third argument */
48
+ export interface ItemMeta {
49
+ /** Whether this item is the currently selected item (interactive mode only) */
50
+ isSelected: boolean
51
+ }
52
+
53
+ export interface VirtualListProps<T> {
54
+ /** Array of items to render */
55
+ items: T[]
56
+
57
+ /** Height of the list viewport in rows */
58
+ height: number
59
+
60
+ /** Height of each item in rows (fixed or function for variable heights) */
61
+ itemHeight?: number | ((item: T, index: number) => number)
62
+
63
+ /** Index to keep visible (scrolls if off-screen). Ignored when interactive=true. */
64
+ scrollTo?: number
65
+
66
+ /** Extra items to render above/below viewport for smooth scrolling (default: 5) */
67
+ overscan?: number
68
+
69
+ /** Maximum items to render at once (default: 100) */
70
+ maxRendered?: number
71
+
72
+ /** Render function for each item. Third arg provides selection metadata. */
73
+ renderItem: (item: T, index: number, meta?: ItemMeta) => React.ReactNode
74
+
75
+ /** Show overflow indicators (▲N/▼N) */
76
+ overflowIndicator?: boolean
77
+
78
+ /** Optional key extractor (defaults to index) */
79
+ keyExtractor?: (item: T, index: number) => string | number
80
+
81
+ /** Width of the list (optional, uses parent width if not specified) */
82
+ width?: number
83
+
84
+ /** Gap between items in rows (default: 0) */
85
+ gap?: number
86
+
87
+ /** Render separator between items (alternative to gap) */
88
+ renderSeparator?: () => React.ReactNode
89
+
90
+ /** Predicate for items already virtualized (e.g. pushed to scrollback).
91
+ * Only a contiguous prefix of matching items is removed from the list.
92
+ * Virtualized items are excluded from rendering — callers can use Static or
93
+ * useScrollback to push them to terminal scrollback separately. */
94
+ virtualized?: (item: T, index: number) => boolean
95
+
96
+ // ── Interactive mode ──────────────────────────────────────────────
97
+
98
+ /** Enable built-in keyboard (j/k, arrows, PgUp/PgDn, Home/End, G) and mouse wheel */
99
+ interactive?: boolean
100
+
101
+ /** Currently selected index (controlled). Managed internally when not provided. */
102
+ selectedIndex?: number
103
+
104
+ /** Called when selection changes (keyboard or mouse wheel navigation) */
105
+ onSelectionChange?: (index: number) => void
106
+
107
+ /** Called when Enter is pressed on the selected item */
108
+ onSelect?: (index: number) => void
109
+
110
+ /** Called when the visible range reaches near the end of the list (infinite scroll). */
111
+ onEndReached?: () => void
112
+ /** How many items from the end to trigger onEndReached. Default: 5 */
113
+ onEndReachedThreshold?: number
114
+
115
+ /** Content rendered after all items inside the scroll container */
116
+ listFooter?: React.ReactNode
117
+ }
118
+
119
+ export interface VirtualListHandle {
120
+ /** Scroll to a specific item index */
121
+ scrollToItem(index: number): void
122
+ }
123
+
124
+ // =============================================================================
125
+ // Constants
126
+ // =============================================================================
127
+
128
+ const DEFAULT_ITEM_HEIGHT = 1
129
+ /** Items to move per mouse wheel tick */
130
+ const WHEEL_STEP = 3
131
+
132
+ /**
133
+ * Padding from edge before scrolling (in items).
134
+ *
135
+ * Vertical lists use padding=2 for more context visibility (you typically
136
+ * want to see what's coming when scrolling through a long list).
137
+ *
138
+ * @see calcEdgeBasedScrollOffset in scroll-utils.ts for the algorithm
139
+ */
140
+ const SCROLL_PADDING = 2
141
+
142
+ // =============================================================================
143
+ // Component
144
+ // =============================================================================
145
+
146
+ /**
147
+ * VirtualList - React-level virtualized list with native silvery scrolling.
148
+ *
149
+ * Thin wrapper around VirtualView that adds interactive mode (keyboard +
150
+ * mouse), virtual item prefix exclusion, and selection metadata injection.
151
+ *
152
+ * Scroll state management:
153
+ * - When scrollTo is defined: actively track and scroll to that index
154
+ * - When scrollTo is undefined: completely freeze scroll state (do nothing)
155
+ *
156
+ * This freeze behavior is critical for multi-column layouts where only one
157
+ * column is "selected" at a time. Non-selected columns must not recalculate
158
+ * their scroll position.
159
+ */
160
+ function VirtualListInner<T>(
161
+ {
162
+ items,
163
+ height,
164
+ itemHeight = DEFAULT_ITEM_HEIGHT,
165
+ scrollTo: scrollToProp,
166
+ overscan,
167
+ maxRendered,
168
+ renderItem,
169
+ overflowIndicator,
170
+ keyExtractor,
171
+ width,
172
+ gap,
173
+ renderSeparator,
174
+ virtualized,
175
+ interactive,
176
+ selectedIndex: selectedIndexProp,
177
+ onSelectionChange,
178
+ onSelect,
179
+ onEndReached,
180
+ onEndReachedThreshold,
181
+ listFooter,
182
+ }: VirtualListProps<T>,
183
+ ref: React.ForwardedRef<VirtualListHandle>,
184
+ ): React.ReactElement {
185
+ // ── Interactive mode: internal selection state ────────────────────
186
+ // Semi-controlled: internal state is the source of truth.
187
+ // Prop syncs initial value and external updates.
188
+ const [internalIndex, setInternalIndex] = useState(selectedIndexProp ?? 0)
189
+ const lastPropRef = useRef(selectedIndexProp)
190
+ if (selectedIndexProp !== undefined && selectedIndexProp !== lastPropRef.current) {
191
+ lastPropRef.current = selectedIndexProp
192
+ setInternalIndex(selectedIndexProp)
193
+ }
194
+ const activeSelection = interactive ? internalIndex : -1
195
+
196
+ const moveTo = useCallback(
197
+ (next: number) => {
198
+ const clamped = Math.max(0, Math.min(next, items.length - 1))
199
+ setInternalIndex(clamped)
200
+ onSelectionChange?.(clamped)
201
+ },
202
+ [items.length, onSelectionChange],
203
+ )
204
+
205
+ // Keyboard input for interactive mode
206
+ useInput(
207
+ (input, key) => {
208
+ if (!interactive) return
209
+ const cur = activeSelection
210
+ if (input === "j" || key.downArrow) moveTo(cur + 1)
211
+ else if (input === "k" || key.upArrow) moveTo(cur - 1)
212
+ else if (input === "G" || key.end) moveTo(items.length - 1)
213
+ else if (key.home) moveTo(0)
214
+ else if (key.pageDown || (input === "d" && key.ctrl)) moveTo(cur + Math.floor(height / 2))
215
+ else if (key.pageUp || (input === "u" && key.ctrl)) moveTo(cur - Math.floor(height / 2))
216
+ else if (key.return) onSelect?.(cur)
217
+ },
218
+ { isActive: interactive },
219
+ )
220
+
221
+ // In interactive mode, scrollTo is derived from selection
222
+ const scrollTo = interactive ? activeSelection : scrollToProp
223
+
224
+ // ── Virtual prefix computation ──────────────────────────────────────
225
+ let virtualizedCount = 0
226
+ if (virtualized) {
227
+ for (let i = 0; i < items.length; i++) {
228
+ if (!virtualized(items[i]!, i)) break
229
+ virtualizedCount++
230
+ }
231
+ }
232
+
233
+ // Slice items to exclude virtual prefix
234
+ const activeItems = virtualizedCount > 0 ? items.slice(virtualizedCount) : items
235
+
236
+ // Adjust scrollTo to account for virtual items
237
+ const adjustedScrollTo = scrollTo !== undefined ? Math.max(0, scrollTo - virtualizedCount) : undefined
238
+
239
+ // ── Adapt props for VirtualView ──────────────────────────────
240
+
241
+ // Convert itemHeight (item,index)=>number to estimateHeight (index)=>number
242
+ const estimateHeight = useMemo(() => {
243
+ if (typeof itemHeight === "number") return itemHeight
244
+ if (virtualizedCount > 0) {
245
+ return (index: number) => itemHeight(activeItems[index]!, index + virtualizedCount)
246
+ }
247
+ return (index: number) => itemHeight(activeItems[index]!, index)
248
+ }, [itemHeight, activeItems, virtualizedCount])
249
+
250
+ // Wrap renderItem to inject ItemMeta (3rd arg) and adjust indices for virtual prefix
251
+ const wrappedRenderItem = useCallback(
252
+ (item: T, index: number): React.ReactNode => {
253
+ const originalIndex = index + virtualizedCount
254
+ const meta: ItemMeta = { isSelected: originalIndex === activeSelection }
255
+ return renderItem(item, originalIndex, meta)
256
+ },
257
+ [renderItem, virtualizedCount, activeSelection],
258
+ )
259
+
260
+ // Wrap keyExtractor to adjust indices for virtual prefix
261
+ const wrappedKeyExtractor = useMemo(() => {
262
+ if (!keyExtractor) return undefined
263
+ if (virtualizedCount === 0) return keyExtractor
264
+ return (item: T, index: number) => keyExtractor(item, index + virtualizedCount)
265
+ }, [keyExtractor, virtualizedCount])
266
+
267
+ // Mouse wheel handler for interactive mode
268
+ const onWheel = useMemo(() => {
269
+ if (!interactive) return undefined
270
+ return (e: { deltaY: number }) => {
271
+ const delta = e.deltaY > 0 ? WHEEL_STEP : -WHEEL_STEP
272
+ moveTo(activeSelection + delta)
273
+ }
274
+ }, [interactive, activeSelection, moveTo])
275
+
276
+ // ── Ref wrapping ───────────────────────────────────────────────────
277
+ const innerRef = useRef<VirtualViewHandle>(null)
278
+
279
+ // Wrap scrollToItem to accept original indices (before virtual adjustment)
280
+ useImperativeHandle(
281
+ ref,
282
+ () => ({
283
+ scrollToItem(index: number) {
284
+ innerRef.current?.scrollToItem(Math.max(0, index - virtualizedCount))
285
+ },
286
+ }),
287
+ [virtualizedCount],
288
+ )
289
+
290
+ // ── Delegate to VirtualView ──────────────────────────────────
291
+ return (
292
+ <VirtualView
293
+ ref={innerRef}
294
+ items={activeItems}
295
+ height={height}
296
+ estimateHeight={estimateHeight}
297
+ scrollTo={adjustedScrollTo}
298
+ scrollPadding={SCROLL_PADDING}
299
+ overscan={overscan}
300
+ maxRendered={maxRendered}
301
+ renderItem={wrappedRenderItem}
302
+ overflowIndicator={overflowIndicator}
303
+ keyExtractor={wrappedKeyExtractor}
304
+ width={width}
305
+ gap={gap}
306
+ renderSeparator={renderSeparator}
307
+ onWheel={onWheel}
308
+ onEndReached={onEndReached}
309
+ onEndReachedThreshold={onEndReachedThreshold}
310
+ listFooter={listFooter}
311
+ />
312
+ )
313
+ }
314
+
315
+ // Export with forwardRef - use type assertion for generic component
316
+ export const VirtualList = forwardRef(VirtualListInner) as <T>(
317
+ props: VirtualListProps<T> & { ref?: React.ForwardedRef<VirtualListHandle> },
318
+ ) => React.ReactElement