@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.
- package/package.json +71 -0
- package/src/animation/easing.ts +38 -0
- package/src/animation/index.ts +18 -0
- package/src/animation/useAnimation.ts +143 -0
- package/src/animation/useInterval.ts +39 -0
- package/src/animation/useLatest.ts +35 -0
- package/src/animation/useTimeout.ts +65 -0
- package/src/animation/useTransition.ts +110 -0
- package/src/animation.ts +24 -0
- package/src/ansi/index.ts +43 -0
- package/src/canvas/index.ts +169 -0
- package/src/cli/ansi.ts +85 -0
- package/src/cli/index.ts +39 -0
- package/src/cli/multi-progress.ts +340 -0
- package/src/cli/progress-bar.ts +222 -0
- package/src/cli/spinner.ts +275 -0
- package/src/components/Badge.tsx +54 -0
- package/src/components/Breadcrumb.tsx +72 -0
- package/src/components/Button.tsx +73 -0
- package/src/components/CommandPalette.tsx +186 -0
- package/src/components/Console.tsx +79 -0
- package/src/components/CursorLine.tsx +71 -0
- package/src/components/Divider.tsx +67 -0
- package/src/components/EditContextDisplay.tsx +164 -0
- package/src/components/ErrorBoundary.tsx +179 -0
- package/src/components/Form.tsx +86 -0
- package/src/components/GridCell.tsx +42 -0
- package/src/components/HorizontalVirtualList.tsx +375 -0
- package/src/components/ModalDialog.tsx +179 -0
- package/src/components/PickerDialog.tsx +208 -0
- package/src/components/PickerList.tsx +93 -0
- package/src/components/ProgressBar.tsx +126 -0
- package/src/components/Screen.tsx +78 -0
- package/src/components/ScrollbackList.tsx +92 -0
- package/src/components/ScrollbackView.tsx +390 -0
- package/src/components/SelectList.tsx +176 -0
- package/src/components/Skeleton.tsx +87 -0
- package/src/components/Spinner.tsx +64 -0
- package/src/components/SplitView.tsx +199 -0
- package/src/components/Table.tsx +139 -0
- package/src/components/Tabs.tsx +203 -0
- package/src/components/TextArea.tsx +264 -0
- package/src/components/TextInput.tsx +240 -0
- package/src/components/Toast.tsx +216 -0
- package/src/components/Toggle.tsx +73 -0
- package/src/components/Tooltip.tsx +60 -0
- package/src/components/TreeView.tsx +212 -0
- package/src/components/Typography.tsx +233 -0
- package/src/components/VirtualList.tsx +318 -0
- package/src/components/VirtualView.tsx +221 -0
- package/src/components/useReadline.ts +213 -0
- package/src/components/useTextArea.ts +648 -0
- package/src/components.ts +133 -0
- package/src/display/Table.tsx +179 -0
- package/src/display/index.ts +13 -0
- package/src/hooks/useTea.ts +133 -0
- package/src/image/Image.tsx +187 -0
- package/src/image/index.ts +15 -0
- package/src/image/kitty-graphics.ts +161 -0
- package/src/image/sixel-encoder.ts +194 -0
- package/src/images.ts +22 -0
- package/src/index.ts +34 -0
- package/src/input/Select.tsx +155 -0
- package/src/input/TextInput.tsx +227 -0
- package/src/input/index.ts +25 -0
- package/src/progress/als-context.ts +160 -0
- package/src/progress/declarative.ts +519 -0
- package/src/progress/index.ts +54 -0
- package/src/progress/step-node.ts +152 -0
- package/src/progress/steps.ts +425 -0
- package/src/progress/task.ts +138 -0
- package/src/progress/tasks.ts +216 -0
- package/src/react/ProgressBar.tsx +146 -0
- package/src/react/Spinner.tsx +74 -0
- package/src/react/Tasks.tsx +144 -0
- package/src/react/context.tsx +145 -0
- package/src/react/index.ts +30 -0
- package/src/types.ts +252 -0
- package/src/utils/eta.ts +155 -0
- package/src/utils/index.ts +13 -0
- package/src/wrappers/index.ts +36 -0
- package/src/wrappers/with-progress.ts +250 -0
- package/src/wrappers/with-select.ts +194 -0
- package/src/wrappers/with-spinner.ts +108 -0
- package/src/wrappers/with-text-input.ts +388 -0
- package/src/wrappers/wrap-emitter.ts +158 -0
- 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
|