@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,179 @@
1
+ /**
2
+ * ErrorBoundary Component
3
+ *
4
+ * Catches JavaScript errors in child component tree and displays a fallback UI.
5
+ * Follows React's error boundary pattern using class component lifecycle methods.
6
+ */
7
+
8
+ import { Component, type ErrorInfo, type ReactNode } from "react"
9
+ import { Box } from "@silvery/react/components/Box"
10
+ import { Text } from "@silvery/react/components/Text"
11
+
12
+ // ============================================================================
13
+ // Props
14
+ // ============================================================================
15
+
16
+ export interface ErrorBoundaryProps {
17
+ /** Child components to render */
18
+ children: ReactNode
19
+ /**
20
+ * Fallback UI to render when an error is caught.
21
+ * Can be a ReactNode or a function that receives error details.
22
+ */
23
+ fallback?: ReactNode | ((error: Error, errorInfo: ErrorInfo) => ReactNode)
24
+ /**
25
+ * Called when an error is caught.
26
+ * Use for logging or error reporting.
27
+ */
28
+ onError?: (error: Error, errorInfo: ErrorInfo) => void
29
+ /**
30
+ * Called when the error is reset (if resetKey or resetKeys change).
31
+ */
32
+ onReset?: () => void
33
+ /**
34
+ * When this key changes, the error boundary resets and tries to render children again.
35
+ * Useful for "retry" functionality.
36
+ */
37
+ resetKey?: string | number
38
+ /**
39
+ * When any element in this array changes (shallow comparison), the error
40
+ * boundary resets and re-mounts children. Useful when the recovery depends
41
+ * on multiple values (e.g., route + data version).
42
+ */
43
+ resetKeys?: unknown[]
44
+ }
45
+
46
+ interface ErrorBoundaryState {
47
+ hasError: boolean
48
+ error: Error | null
49
+ errorInfo: ErrorInfo | null
50
+ }
51
+
52
+ // ============================================================================
53
+ // Component
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Error boundary that catches render errors in its children.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * // Basic usage with default fallback
62
+ * <ErrorBoundary>
63
+ * <MyComponent />
64
+ * </ErrorBoundary>
65
+ *
66
+ * // Custom fallback
67
+ * <ErrorBoundary fallback={<Text color="red">Something went wrong</Text>}>
68
+ * <MyComponent />
69
+ * </ErrorBoundary>
70
+ *
71
+ * // Function fallback with error details
72
+ * <ErrorBoundary
73
+ * fallback={(error, errorInfo) => (
74
+ * <Box flexDirection="column">
75
+ * <Text color="red">Error: {error.message}</Text>
76
+ * <Text dim>{errorInfo.componentStack}</Text>
77
+ * </Box>
78
+ * )}
79
+ * >
80
+ * <MyComponent />
81
+ * </ErrorBoundary>
82
+ *
83
+ * // With error reporting
84
+ * <ErrorBoundary
85
+ * onError={(error, errorInfo) => {
86
+ * logErrorToService(error, errorInfo);
87
+ * }}
88
+ * >
89
+ * <MyComponent />
90
+ * </ErrorBoundary>
91
+ *
92
+ * // With reset functionality
93
+ * const [resetKey, setResetKey] = useState(0);
94
+ * <ErrorBoundary
95
+ * resetKey={resetKey}
96
+ * fallback={
97
+ * <Box>
98
+ * <Text color="red">Error!</Text>
99
+ * <Text> Press r to retry</Text>
100
+ * </Box>
101
+ * }
102
+ * onReset={() => console.log('Retrying...')}
103
+ * >
104
+ * <MyComponent />
105
+ * </ErrorBoundary>
106
+ * // On 'r' key: setResetKey(k => k + 1)
107
+ * ```
108
+ */
109
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
110
+ state: ErrorBoundaryState = {
111
+ hasError: false,
112
+ error: null,
113
+ errorInfo: null,
114
+ }
115
+
116
+ static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
117
+ return { hasError: true, error }
118
+ }
119
+
120
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
121
+ this.setState({ errorInfo })
122
+ this.props.onError?.(error, errorInfo)
123
+ }
124
+
125
+ componentDidUpdate(prevProps: ErrorBoundaryProps): void {
126
+ if (!this.state.hasError) return
127
+
128
+ // Reset error state when resetKey changes
129
+ const resetKeyChanged = this.props.resetKey !== undefined && prevProps.resetKey !== this.props.resetKey
130
+
131
+ // Reset error state when any element in resetKeys changes (shallow comparison)
132
+ const resetKeysChanged =
133
+ this.props.resetKeys !== undefined &&
134
+ (this.props.resetKeys.length !== prevProps.resetKeys?.length ||
135
+ this.props.resetKeys.some((key, i) => key !== prevProps.resetKeys?.[i]))
136
+
137
+ if (resetKeyChanged || resetKeysChanged) {
138
+ this.props.onReset?.()
139
+ this.setState({ hasError: false, error: null, errorInfo: null })
140
+ }
141
+ }
142
+
143
+ render(): ReactNode {
144
+ if (this.state.hasError) {
145
+ const { fallback } = this.props
146
+ const { error, errorInfo } = this.state
147
+
148
+ // If fallback is a function, call it with error details.
149
+ // errorInfo may be null on the first render (getDerivedStateFromError runs
150
+ // before componentDidCatch), so provide a minimal default.
151
+ if (typeof fallback === "function" && error) {
152
+ const info = errorInfo ?? ({ componentStack: null } as unknown as ErrorInfo)
153
+ return fallback(error, info)
154
+ }
155
+
156
+ // If fallback is provided, use it
157
+ if (fallback !== undefined) {
158
+ return fallback as ReactNode
159
+ }
160
+
161
+ // Default fallback: red bordered box with error message
162
+ return (
163
+ <Box borderStyle="single" borderColor="$error" padding={1} flexDirection="column">
164
+ <Text color="$error" bold>
165
+ Error
166
+ </Text>
167
+ {error && <Text color="$error">{error.message}</Text>}
168
+ {errorInfo?.componentStack && (
169
+ <Text dim wrap="truncate">
170
+ {errorInfo.componentStack.split("\n").slice(0, 3).join("\n")}
171
+ </Text>
172
+ )}
173
+ </Box>
174
+ )
175
+ }
176
+
177
+ return this.props.children
178
+ }
179
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Form + FormField Components
3
+ *
4
+ * Layout wrappers for form inputs. Form provides vertical grouping and
5
+ * an optional submit handler. FormField provides label, error display,
6
+ * and consistent spacing between fields.
7
+ *
8
+ * Usage:
9
+ * ```tsx
10
+ * <Form onSubmit={handleSubmit}>
11
+ * <FormField label="Name" error={errors.name}>
12
+ * <TextInput value={name} onChange={setName} />
13
+ * </FormField>
14
+ * <FormField label="Email">
15
+ * <TextInput value={email} onChange={setEmail} />
16
+ * </FormField>
17
+ * </Form>
18
+ * ```
19
+ */
20
+ import React from "react"
21
+ import { Box } from "@silvery/react/components/Box"
22
+ import { Text } from "@silvery/react/components/Text"
23
+
24
+ // =============================================================================
25
+ // Types
26
+ // =============================================================================
27
+
28
+ export interface FormProps {
29
+ /** Called when Enter is pressed within the form (optional) */
30
+ onSubmit?: () => void
31
+ /** Gap between form fields (default: 1) */
32
+ gap?: number
33
+ /** Form children (typically FormField components) */
34
+ children: React.ReactNode
35
+ }
36
+
37
+ export interface FormFieldProps {
38
+ /** Field label text */
39
+ label: string
40
+ /** Error message to display below the input */
41
+ error?: string
42
+ /** Optional description text below the label */
43
+ description?: string
44
+ /** Whether the field is required (shows * indicator) */
45
+ required?: boolean
46
+ /** Field input children */
47
+ children: React.ReactNode
48
+ }
49
+
50
+ // =============================================================================
51
+ // Components
52
+ // =============================================================================
53
+
54
+ /**
55
+ * Vertical form layout container.
56
+ *
57
+ * Groups FormField children with consistent spacing. The optional `onSubmit`
58
+ * callback is provided for parent-level form submission logic.
59
+ */
60
+ export function Form({ onSubmit: _onSubmit, gap = 1, children }: FormProps): React.ReactElement {
61
+ return (
62
+ <Box flexDirection="column" gap={gap}>
63
+ {children}
64
+ </Box>
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Form field wrapper providing label, error display, and spacing.
70
+ *
71
+ * Renders a label above the input with optional required indicator,
72
+ * description text, and error message in `$error` color.
73
+ */
74
+ export function FormField({ label, error, description, required, children }: FormFieldProps): React.ReactElement {
75
+ return (
76
+ <Box flexDirection="column">
77
+ <Text color="$muted" bold>
78
+ {label}
79
+ {required && <Text color="$error"> *</Text>}
80
+ </Text>
81
+ {description && <Text color="$disabledfg">{description}</Text>}
82
+ <Box>{children}</Box>
83
+ {error && <Text color="$error">{error}</Text>}
84
+ </Box>
85
+ )
86
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * GridCell — auto-registering wrapper for items in a 2D grid.
3
+ *
4
+ * Wraps a child component and automatically registers its screen position
5
+ * in the PositionRegistry. Unregisters on unmount.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * <VirtualList
10
+ * items={column.items}
11
+ * renderItem={(item, idx) => (
12
+ * <GridCell sectionIndex={colIndex} itemIndex={idx}>
13
+ * <Card {...item} />
14
+ * </GridCell>
15
+ * )}
16
+ * />
17
+ * ```
18
+ */
19
+
20
+ import type { ReactNode } from "react"
21
+ import { Box } from "@silvery/react/components/Box"
22
+ import { useGridPosition } from "@silvery/react/hooks/useGridPosition"
23
+
24
+ export interface GridCellProps {
25
+ /** Section index (e.g., column index in a kanban board). */
26
+ sectionIndex: number
27
+ /** Item index within the section. */
28
+ itemIndex: number
29
+ /** Child content to render. */
30
+ children: ReactNode
31
+ }
32
+
33
+ /**
34
+ * A thin wrapper that auto-registers its screen position in the PositionRegistry.
35
+ *
36
+ * Renders a transparent Box (no visual impact) around children.
37
+ * Position tracking uses useScreenRectCallback (zero re-renders).
38
+ */
39
+ export function GridCell({ sectionIndex, itemIndex, children }: GridCellProps) {
40
+ useGridPosition(sectionIndex, itemIndex)
41
+ return <Box>{children}</Box>
42
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * HorizontalVirtualList Component
3
+ *
4
+ * React-level virtualization for horizontal lists. Only renders items within the
5
+ * visible viewport plus overscan. Items outside the viewport are not rendered —
6
+ * scrolling is achieved by changing which items are in the render window.
7
+ *
8
+ * Uses the shared useVirtualization hook for scroll state management.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import { HorizontalVirtualList } from '@silvery/react';
13
+ *
14
+ * <HorizontalVirtualList
15
+ * items={columns}
16
+ * width={80}
17
+ * itemWidth={20}
18
+ * scrollTo={selectedIndex}
19
+ * renderItem={(column, index) => (
20
+ * <Column key={column.id} column={column} isSelected={index === selected} />
21
+ * )}
22
+ * />
23
+ * ```
24
+ */
25
+ import React, { forwardRef, useImperativeHandle } from "react"
26
+ import { useVirtualization } from "@silvery/react/hooks/useVirtualization"
27
+ import { Box } from "@silvery/react/components/Box"
28
+ import { Text } from "@silvery/react/components/Text"
29
+
30
+ // =============================================================================
31
+ // Types
32
+ // =============================================================================
33
+
34
+ export interface HorizontalVirtualListProps<T> {
35
+ /** Array of items to render */
36
+ items: T[]
37
+
38
+ /** Width of the list viewport in columns */
39
+ width: number
40
+
41
+ /** Width of each item (fixed number or function for variable widths) */
42
+ itemWidth: number | ((item: T, index: number) => number)
43
+
44
+ /** Index to keep visible (scrolls if off-screen) */
45
+ scrollTo?: number
46
+
47
+ /** Extra items to render left/right of viewport for smooth scrolling (default: 1) */
48
+ overscan?: number
49
+
50
+ /** Maximum items to render at once (default: 20) */
51
+ maxRendered?: number
52
+
53
+ /** Render function for each item */
54
+ renderItem: (item: T, index: number) => React.ReactNode
55
+
56
+ /** Show built-in overflow indicators (◀N/▶N) */
57
+ overflowIndicator?: boolean
58
+
59
+ /** Custom overflow indicator renderer. Replaces built-in indicators when provided. */
60
+ renderOverflowIndicator?: (direction: "before" | "after", hiddenCount: number) => React.ReactNode
61
+
62
+ /** Width in chars of each overflow indicator (default: 0). Reserves viewport space for indicators. */
63
+ overflowIndicatorWidth?: number
64
+
65
+ /** Optional key extractor (defaults to index) */
66
+ keyExtractor?: (item: T, index: number) => string | number
67
+
68
+ /** Height of the list (optional, uses parent height if not specified) */
69
+ height?: number
70
+
71
+ /** Gap between items in columns (default: 0) */
72
+ gap?: number
73
+
74
+ /** Render separator between items (alternative to gap) */
75
+ renderSeparator?: () => React.ReactNode
76
+ }
77
+
78
+ export interface HorizontalVirtualListHandle {
79
+ /** Scroll to a specific item index */
80
+ scrollToItem(index: number): void
81
+ }
82
+
83
+ // =============================================================================
84
+ // Constants
85
+ // =============================================================================
86
+
87
+ const DEFAULT_OVERSCAN = 1
88
+ const DEFAULT_MAX_RENDERED = 20
89
+
90
+ /**
91
+ * Padding from edge before scrolling (in items).
92
+ *
93
+ * Horizontal lists use padding=1 since columns are wider and fewer fit on screen.
94
+ * Vertical lists (VirtualList) use padding=2 for more context visibility.
95
+ *
96
+ * @see calcEdgeBasedScrollOffset in scroll-utils.ts for the algorithm
97
+ */
98
+ const SCROLL_PADDING = 1
99
+
100
+ // =============================================================================
101
+ // Helpers
102
+ // =============================================================================
103
+
104
+ // /** Calculate average item width for estimating visible count. */
105
+ // function calcAvgItemWidth<T>(items: T[], itemWidth: number | ((item: T, index: number) => number)): number {
106
+ // if (typeof itemWidth === "number") return itemWidth
107
+ // if (items.length === 0) return 1
108
+ // const n = Math.min(items.length, 10)
109
+ // let sum = 0
110
+ // for (let i = 0; i < n; i++) sum += itemWidth(items[i], i)
111
+ // return sum / n
112
+ // }
113
+
114
+ /**
115
+ * Calculate total width of all items including gaps.
116
+ */
117
+ function calcTotalItemsWidth<T>(
118
+ items: T[],
119
+ itemWidth: number | ((item: T, index: number) => number),
120
+ gap: number,
121
+ ): number {
122
+ if (items.length === 0) return 0
123
+ if (typeof itemWidth === "number") return items.length * itemWidth + (items.length - 1) * gap
124
+ let total = 0
125
+ for (let i = 0; i < items.length; i++) {
126
+ total += itemWidth(items[i], i) + (i > 0 ? gap : 0)
127
+ }
128
+ return total
129
+ }
130
+
131
+ /**
132
+ * Count how many items actually fit in the viewport starting from a given index.
133
+ * More accurate than average-based estimation for variable widths.
134
+ */
135
+ function calcActualVisibleCount<T>(
136
+ items: T[],
137
+ startFrom: number,
138
+ viewport: number,
139
+ itemWidth: number | ((item: T, index: number) => number),
140
+ gap: number,
141
+ ): number {
142
+ // Count items that FULLY fit within the viewport (floor semantics).
143
+ // Overflow indicators use this count for accurate "hidden items" reporting.
144
+ // The rendering loop separately limits items to the viewport boundary.
145
+ if (typeof itemWidth === "number") {
146
+ return Math.max(1, Math.floor((viewport + gap) / (itemWidth + gap)))
147
+ }
148
+ let usedSize = 0
149
+ let count = 0
150
+ for (let i = startFrom; i < items.length; i++) {
151
+ const size = itemWidth(items[i], i)
152
+ const sizeWithGap = size + (count > 0 ? gap : 0)
153
+ if (usedSize + sizeWithGap > viewport) break
154
+ usedSize += sizeWithGap
155
+ count++
156
+ }
157
+ return Math.max(1, count)
158
+ }
159
+
160
+ /**
161
+ * Calculate the physical right edge position of a target item relative to
162
+ * the viewport, given a scroll offset. Returns the pixel position past the
163
+ * viewport right edge (positive = clipped, zero/negative = fully visible).
164
+ */
165
+ function calcItemOverflow<T>(
166
+ items: T[],
167
+ scrollOffset: number,
168
+ targetIndex: number,
169
+ viewport: number,
170
+ itemWidth: number | ((item: T, index: number) => number),
171
+ gap: number,
172
+ ): number {
173
+ if (targetIndex < scrollOffset || targetIndex >= items.length) return 0
174
+ // Sum widths from scrollOffset to targetIndex (inclusive)
175
+ let pos = 0
176
+ for (let i = scrollOffset; i <= targetIndex; i++) {
177
+ const w = typeof itemWidth === "number" ? itemWidth : itemWidth(items[i]!, i)
178
+ pos += w + (i > scrollOffset ? gap : 0)
179
+ }
180
+ return pos - viewport
181
+ }
182
+
183
+ // =============================================================================
184
+ // Component
185
+ // =============================================================================
186
+
187
+ /**
188
+ * HorizontalVirtualList - React-level virtualized horizontal list.
189
+ *
190
+ * Only renders items within the visible viewport plus overscan.
191
+ *
192
+ * Scroll state management (via useVirtualization hook):
193
+ * - When scrollTo is defined: actively track and scroll to that index
194
+ * - When scrollTo is undefined: completely freeze scroll state (do nothing)
195
+ *
196
+ * This freeze behavior is critical for multi-column layouts where only one
197
+ * column is "selected" at a time. Non-selected columns must not recalculate
198
+ * their scroll position.
199
+ */
200
+ function HorizontalVirtualListInner<T>(
201
+ {
202
+ items,
203
+ width,
204
+ itemWidth,
205
+ scrollTo,
206
+ overscan = DEFAULT_OVERSCAN,
207
+ maxRendered = DEFAULT_MAX_RENDERED,
208
+ renderItem,
209
+ overflowIndicator,
210
+ renderOverflowIndicator,
211
+ overflowIndicatorWidth = 0,
212
+ keyExtractor,
213
+ height,
214
+ gap = 0,
215
+ renderSeparator,
216
+ }: HorizontalVirtualListProps<T>,
217
+ ref: React.ForwardedRef<HorizontalVirtualListHandle>,
218
+ ): React.ReactElement {
219
+ // Always reserve indicator space when an overflow indicator is configured.
220
+ // This prevents layout shift: without reservation, the first render uses full width,
221
+ // then a second render detects overflow and shrinks the viewport by 2 chars,
222
+ // causing all columns to reflow visibly.
223
+ const totalItemsWidth = calcTotalItemsWidth(items, itemWidth, gap)
224
+ const allItemsFit = totalItemsWidth <= width
225
+ const hasIndicatorRenderer = renderOverflowIndicator != null || overflowIndicator === true
226
+ const indicatorReserved = hasIndicatorRenderer ? overflowIndicatorWidth * 2 : 0
227
+ const effectiveViewport = Math.max(1, width - indicatorReserved)
228
+
229
+ // Use shared virtualization hook for scroll state management
230
+ const { startIndex, endIndex, scrollOffset, scrollToItem } = useVirtualization({
231
+ items,
232
+ viewportSize: effectiveViewport,
233
+ itemSize: itemWidth,
234
+ scrollTo,
235
+ scrollPadding: SCROLL_PADDING,
236
+ overscan,
237
+ maxRendered,
238
+ gap,
239
+ })
240
+
241
+ // Expose scrollToItem method via ref for imperative scrolling
242
+ useImperativeHandle(ref, () => ({ scrollToItem }), [scrollToItem])
243
+
244
+ // Empty state
245
+ if (items.length === 0) {
246
+ return (
247
+ <Box flexDirection="row" width={width} height={height}>
248
+ {/* Empty - nothing to render */}
249
+ </Box>
250
+ )
251
+ }
252
+
253
+ // When all items fit, override scrollOffset to 0. useVirtualization may compute
254
+ // a non-zero offset due to average-based estimation with variable widths
255
+ // (e.g., collapsed=3 vs expanded=76 averages to 39.5, underestimating visible count).
256
+ let displayScrollOffset = allItemsFit ? 0 : scrollOffset
257
+
258
+ // Fix partial visibility: useVirtualization reserves space for both overflow
259
+ // indicators (left + right), but at the edges only one shows. Recalculate
260
+ // with the actual indicator overhead to see if items truly don't fit.
261
+ // If they do fit with actual indicators, keep the current offset.
262
+ // If they don't, bump the offset to fully reveal the cursor item.
263
+ if (scrollTo !== undefined && !allItemsFit && scrollTo >= displayScrollOffset) {
264
+ // Determine which indicators would show at the current offset
265
+ const wouldShowLeft = hasIndicatorRenderer && displayScrollOffset > 0
266
+ const prelimVisibleCount = calcActualVisibleCount(items, displayScrollOffset, effectiveViewport, itemWidth, gap)
267
+ const wouldShowRight = hasIndicatorRenderer && items.length - displayScrollOffset - prelimVisibleCount > 0
268
+ // Actual viewport uses only the indicators that will actually render
269
+ const actualIndicatorWidth =
270
+ (wouldShowLeft ? overflowIndicatorWidth : 0) + (wouldShowRight ? overflowIndicatorWidth : 0)
271
+ const actualViewport = Math.max(1, width - actualIndicatorWidth)
272
+
273
+ const overflow = calcItemOverflow(items, displayScrollOffset, scrollTo, actualViewport, itemWidth, gap)
274
+ if (overflow > 0) {
275
+ // Scroll right by 1 to push the partially clipped item into full view.
276
+ const maxOffset = Math.max(0, items.length - 1)
277
+ displayScrollOffset = Math.min(maxOffset, displayScrollOffset + 1)
278
+ }
279
+ }
280
+
281
+ // Compute how many items actually fit starting from the display scroll offset.
282
+ // Uses actual item widths rather than averages for accurate overflow detection.
283
+ const visibleCount = calcActualVisibleCount(items, displayScrollOffset, effectiveViewport, itemWidth, gap)
284
+
285
+ // Viewport-based item window: render items from displayScrollOffset that fit in the
286
+ // viewport, intersected with useVirtualization's render window (respects maxRendered).
287
+ // Only render items that fully fit — partial items at the edge add no visual value
288
+ // in terminal UI and can cause layout issues when flexShrink=0 items overflow.
289
+ const vpStart = Math.max(startIndex, displayScrollOffset)
290
+ const rawVpEnd = Math.min(endIndex, displayScrollOffset + visibleCount + overscan)
291
+ let vpEnd = vpStart
292
+ let usedWidth = 0
293
+ for (let i = vpStart; i < rawVpEnd; i++) {
294
+ const w = typeof itemWidth === "number" ? itemWidth : itemWidth(items[i]!, i)
295
+ const gapWidth = vpEnd > vpStart ? gap : 0
296
+ if (usedWidth > 0 && usedWidth + gapWidth + w > effectiveViewport) break
297
+ usedWidth += w + gapWidth
298
+ vpEnd = i + 1
299
+ }
300
+ const visibleItems = items.slice(vpStart, vpEnd)
301
+
302
+ // Viewport-based overflow detection
303
+ const overflowBefore = displayScrollOffset
304
+ const overflowAfter = Math.max(0, items.length - displayScrollOffset - visibleCount)
305
+
306
+ // Only render overflow indicators when there are actually hidden items in that direction.
307
+ // Space is still reserved via indicatorReserved/effectiveViewport to prevent layout shift;
308
+ // when an indicator is not shown, an empty spacer of the same width fills its slot.
309
+ const hasCustomIndicator = renderOverflowIndicator != null
310
+ const showIndicators = hasCustomIndicator || overflowIndicator === true
311
+ const showLeftIndicator = showIndicators && overflowBefore > 0
312
+ const showRightIndicator = showIndicators && overflowAfter > 0
313
+
314
+ return (
315
+ <Box flexDirection="row" width={width} height={height}>
316
+ {/* Left overflow indicator — outside overflow container to avoid being clipped */}
317
+ {showLeftIndicator &&
318
+ (hasCustomIndicator ? (
319
+ renderOverflowIndicator("before", overflowBefore)
320
+ ) : (
321
+ <Box flexShrink={0}>
322
+ <Text color="$inverse" backgroundColor="$inverse-bg">
323
+ ◀{overflowBefore}
324
+ </Text>
325
+ </Box>
326
+ ))}
327
+ {/* Reserve indicator space when configured but not showing (prevents layout shift) */}
328
+ {showIndicators && !showLeftIndicator && overflowIndicatorWidth > 0 && (
329
+ <Box width={overflowIndicatorWidth} flexShrink={0} />
330
+ )}
331
+
332
+ {/* Overflow container — clips items that extend beyond the viewport */}
333
+ <Box flexGrow={1} flexDirection="row" overflow="hidden">
334
+ {/* Render visible items — flexShrink={0} prevents flex from shrinking
335
+ overscan items; they render at full size and get clipped by overflow="hidden" */}
336
+ {visibleItems.map((item, i) => {
337
+ const actualIndex = vpStart + i
338
+ const key = keyExtractor ? keyExtractor(item, actualIndex) : actualIndex
339
+ const isLast = i === visibleItems.length - 1
340
+
341
+ return (
342
+ <React.Fragment key={key}>
343
+ <Box flexShrink={0}>{renderItem(item, actualIndex)}</Box>
344
+ {!isLast && renderSeparator && renderSeparator()}
345
+ {!isLast && gap > 0 && !renderSeparator && <Box width={gap} flexShrink={0} />}
346
+ </React.Fragment>
347
+ )
348
+ })}
349
+ </Box>
350
+
351
+ {/* Right overflow indicator — outside overflow container to avoid being clipped */}
352
+ {showRightIndicator &&
353
+ (hasCustomIndicator ? (
354
+ renderOverflowIndicator("after", overflowAfter)
355
+ ) : (
356
+ <Box flexShrink={0}>
357
+ <Text color="$inverse" backgroundColor="$inverse-bg">
358
+ {overflowAfter}▶
359
+ </Text>
360
+ </Box>
361
+ ))}
362
+ {/* Reserve indicator space when configured but not showing (prevents layout shift) */}
363
+ {showIndicators && !showRightIndicator && overflowIndicatorWidth > 0 && (
364
+ <Box width={overflowIndicatorWidth} flexShrink={0} />
365
+ )}
366
+ </Box>
367
+ )
368
+ }
369
+
370
+ // Export with forwardRef - use type assertion for generic component
371
+ export const HorizontalVirtualList = forwardRef(HorizontalVirtualListInner) as <T>(
372
+ props: HorizontalVirtualListProps<T> & {
373
+ ref?: React.ForwardedRef<HorizontalVirtualListHandle>
374
+ },
375
+ ) => React.ReactElement