@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,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
|