@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast/Notification Component + useToast Hook
|
|
3
|
+
*
|
|
4
|
+
* Provides a toast notification system with auto-dismiss capability.
|
|
5
|
+
* `useToast()` returns `{ toast, toasts, dismiss }`. Toasts render as a
|
|
6
|
+
* vertical stack and auto-dismiss after a configurable duration.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* function App() {
|
|
11
|
+
* const { toast, toasts } = useToast()
|
|
12
|
+
*
|
|
13
|
+
* return (
|
|
14
|
+
* <Box flexDirection="column">
|
|
15
|
+
* <Button label="Save" onPress={() => {
|
|
16
|
+
* toast({ title: "Saved", variant: "success", duration: 3000 })
|
|
17
|
+
* }} />
|
|
18
|
+
* <ToastContainer toasts={toasts} />
|
|
19
|
+
* </Box>
|
|
20
|
+
* )
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import React, { useCallback, useEffect, useRef, useState } from "react"
|
|
25
|
+
import { Box } from "@silvery/react/components/Box"
|
|
26
|
+
import { Text } from "@silvery/react/components/Text"
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Types
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
export type ToastVariant = "default" | "success" | "error" | "warning" | "info"
|
|
33
|
+
|
|
34
|
+
export interface ToastData {
|
|
35
|
+
/** Unique toast ID (auto-generated if not provided) */
|
|
36
|
+
id: string
|
|
37
|
+
/** Toast title text */
|
|
38
|
+
title: string
|
|
39
|
+
/** Optional description text */
|
|
40
|
+
description?: string
|
|
41
|
+
/** Visual variant (default: "default") */
|
|
42
|
+
variant: ToastVariant
|
|
43
|
+
/** Auto-dismiss duration in ms (default: 3000, 0 = no auto-dismiss) */
|
|
44
|
+
duration: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ToastOptions {
|
|
48
|
+
/** Toast title text */
|
|
49
|
+
title: string
|
|
50
|
+
/** Optional description text */
|
|
51
|
+
description?: string
|
|
52
|
+
/** Visual variant (default: "default") */
|
|
53
|
+
variant?: ToastVariant
|
|
54
|
+
/** Auto-dismiss duration in ms (default: 3000, 0 = no auto-dismiss) */
|
|
55
|
+
duration?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface UseToastResult {
|
|
59
|
+
/** Show a new toast notification */
|
|
60
|
+
toast: (options: ToastOptions) => string
|
|
61
|
+
/** Currently visible toasts */
|
|
62
|
+
toasts: ToastData[]
|
|
63
|
+
/** Dismiss a specific toast by ID */
|
|
64
|
+
dismiss: (id: string) => void
|
|
65
|
+
/** Dismiss all toasts */
|
|
66
|
+
dismissAll: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ToastContainerProps {
|
|
70
|
+
/** Toasts to render */
|
|
71
|
+
toasts: ToastData[]
|
|
72
|
+
/** Maximum visible toasts (default: 5) */
|
|
73
|
+
maxVisible?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ToastItemProps {
|
|
77
|
+
/** Toast data to render */
|
|
78
|
+
toast: ToastData
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Constants
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
const DEFAULT_DURATION = 3000
|
|
86
|
+
|
|
87
|
+
const VARIANT_COLORS: Record<ToastVariant, string> = {
|
|
88
|
+
default: "$fg",
|
|
89
|
+
success: "$success",
|
|
90
|
+
error: "$error",
|
|
91
|
+
warning: "$warning",
|
|
92
|
+
info: "$info",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const VARIANT_ICONS: Record<ToastVariant, string> = {
|
|
96
|
+
default: "i",
|
|
97
|
+
success: "+",
|
|
98
|
+
error: "x",
|
|
99
|
+
warning: "!",
|
|
100
|
+
info: "i",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// =============================================================================
|
|
104
|
+
// Hook
|
|
105
|
+
// =============================================================================
|
|
106
|
+
|
|
107
|
+
let nextToastId = 0
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Hook for managing toast notifications.
|
|
111
|
+
*
|
|
112
|
+
* Returns a `toast()` function to create notifications, the current list
|
|
113
|
+
* of `toasts`, and `dismiss`/`dismissAll` functions for manual removal.
|
|
114
|
+
* Toasts auto-dismiss after `duration` ms (default: 3000).
|
|
115
|
+
*/
|
|
116
|
+
export function useToast(): UseToastResult {
|
|
117
|
+
const [toasts, setToasts] = useState<ToastData[]>([])
|
|
118
|
+
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
|
119
|
+
|
|
120
|
+
const dismiss = useCallback((id: string) => {
|
|
121
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
122
|
+
const timer = timersRef.current.get(id)
|
|
123
|
+
if (timer) {
|
|
124
|
+
clearTimeout(timer)
|
|
125
|
+
timersRef.current.delete(id)
|
|
126
|
+
}
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
const dismissAll = useCallback(() => {
|
|
130
|
+
setToasts([])
|
|
131
|
+
for (const timer of timersRef.current.values()) {
|
|
132
|
+
clearTimeout(timer)
|
|
133
|
+
}
|
|
134
|
+
timersRef.current.clear()
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
const toast = useCallback(
|
|
138
|
+
(options: ToastOptions): string => {
|
|
139
|
+
const id = `toast-${++nextToastId}`
|
|
140
|
+
const data: ToastData = {
|
|
141
|
+
id,
|
|
142
|
+
title: options.title,
|
|
143
|
+
description: options.description,
|
|
144
|
+
variant: options.variant ?? "default",
|
|
145
|
+
duration: options.duration ?? DEFAULT_DURATION,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setToasts((prev) => [...prev, data])
|
|
149
|
+
|
|
150
|
+
if (data.duration > 0) {
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
dismiss(id)
|
|
153
|
+
}, data.duration)
|
|
154
|
+
timersRef.current.set(id, timer)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return id
|
|
158
|
+
},
|
|
159
|
+
[dismiss],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// Cleanup timers on unmount
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const timers = timersRef.current
|
|
165
|
+
return () => {
|
|
166
|
+
for (const timer of timers.values()) {
|
|
167
|
+
clearTimeout(timer)
|
|
168
|
+
}
|
|
169
|
+
timers.clear()
|
|
170
|
+
}
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
173
|
+
return { toast, toasts, dismiss, dismissAll }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =============================================================================
|
|
177
|
+
// Components
|
|
178
|
+
// =============================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Single toast notification item.
|
|
182
|
+
*
|
|
183
|
+
* Renders a bordered box with variant-colored icon, title, and optional
|
|
184
|
+
* description text.
|
|
185
|
+
*/
|
|
186
|
+
export function ToastItem({ toast }: ToastItemProps): React.ReactElement {
|
|
187
|
+
const color = VARIANT_COLORS[toast.variant]
|
|
188
|
+
const icon = VARIANT_ICONS[toast.variant]
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<Box borderStyle="single" borderColor="$border" paddingX={1} backgroundColor="$surface-bg">
|
|
192
|
+
<Text color={color} bold>
|
|
193
|
+
[{icon}]
|
|
194
|
+
</Text>
|
|
195
|
+
<Text> {toast.title}</Text>
|
|
196
|
+
{toast.description && <Text color="$muted"> {toast.description}</Text>}
|
|
197
|
+
</Box>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Container that renders a stack of toast notifications.
|
|
203
|
+
*
|
|
204
|
+
* Place at the bottom of your layout to show toasts as they appear.
|
|
205
|
+
*/
|
|
206
|
+
export function ToastContainer({ toasts, maxVisible = 5 }: ToastContainerProps): React.ReactElement {
|
|
207
|
+
const visible = toasts.slice(-maxVisible)
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<Box flexDirection="column">
|
|
211
|
+
{visible.map((t) => (
|
|
212
|
+
<ToastItem key={t.id} toast={t} />
|
|
213
|
+
))}
|
|
214
|
+
</Box>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toggle Component
|
|
3
|
+
*
|
|
4
|
+
* A focusable checkbox-style toggle control. Integrates with the silvery focus
|
|
5
|
+
* system and responds to Space key to toggle the value.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [enabled, setEnabled] = useState(false)
|
|
10
|
+
* <Toggle value={enabled} onChange={setEnabled} label="Dark mode" />
|
|
11
|
+
*
|
|
12
|
+
* // With explicit active control (bypasses focus system)
|
|
13
|
+
* <Toggle value={on} onChange={setOn} label="Option" isActive={isEditing} />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import React from "react"
|
|
17
|
+
import { useFocusable } from "@silvery/react/hooks/useFocusable"
|
|
18
|
+
import { useInput } from "@silvery/react/hooks/useInput"
|
|
19
|
+
import { Box } from "@silvery/react/components/Box"
|
|
20
|
+
import { Text } from "@silvery/react/components/Text"
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Types
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
export interface ToggleProps {
|
|
27
|
+
/** Whether the toggle is on */
|
|
28
|
+
value: boolean
|
|
29
|
+
/** Called when value changes */
|
|
30
|
+
onChange: (value: boolean) => void
|
|
31
|
+
/** Label text */
|
|
32
|
+
label?: string
|
|
33
|
+
/** Whether input is active (default: from focus system) */
|
|
34
|
+
isActive?: boolean
|
|
35
|
+
/** Test ID for focus system */
|
|
36
|
+
testID?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Component
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Focusable toggle (checkbox) control.
|
|
45
|
+
*
|
|
46
|
+
* Renders `[x]` when on, `[ ]` when off. When focused, the checkbox indicator
|
|
47
|
+
* is rendered with inverse styling for visibility.
|
|
48
|
+
*/
|
|
49
|
+
export function Toggle({ value, onChange, label, isActive, testID }: ToggleProps): React.ReactElement {
|
|
50
|
+
const { focused } = useFocusable()
|
|
51
|
+
|
|
52
|
+
// isActive prop overrides focus state (same pattern as TextInput)
|
|
53
|
+
const active = isActive ?? focused
|
|
54
|
+
|
|
55
|
+
useInput(
|
|
56
|
+
(_input, key) => {
|
|
57
|
+
// Space toggles the value
|
|
58
|
+
if (_input === " " && !key.ctrl && !key.meta && !key.shift) {
|
|
59
|
+
onChange(!value)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ isActive: active },
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const indicator = value ? "[x]" : "[ ]"
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Box focusable testID={testID}>
|
|
69
|
+
<Text inverse={active}>{indicator}</Text>
|
|
70
|
+
{label && <Text> {label}</Text>}
|
|
71
|
+
</Box>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooltip Component
|
|
3
|
+
*
|
|
4
|
+
* Shows contextual help text near the target element. In a terminal UI,
|
|
5
|
+
* the tooltip renders inline below the target since there is no floating
|
|
6
|
+
* layer. Visibility is controlled via the `show` prop.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <Tooltip content="Delete permanently" show={isFocused}>
|
|
11
|
+
* <Button label="Delete" onPress={handleDelete} />
|
|
12
|
+
* </Tooltip>
|
|
13
|
+
*
|
|
14
|
+
* // Always visible
|
|
15
|
+
* <Tooltip content="This action cannot be undone" show>
|
|
16
|
+
* <Text>Dangerous action</Text>
|
|
17
|
+
* </Tooltip>
|
|
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 TooltipProps {
|
|
29
|
+
/** Tooltip text content */
|
|
30
|
+
content: string
|
|
31
|
+
/** Whether the tooltip is visible (default: false) */
|
|
32
|
+
show?: boolean
|
|
33
|
+
/** Tooltip children (target element) */
|
|
34
|
+
children: React.ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Component
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Contextual tooltip that appears below its children.
|
|
43
|
+
*
|
|
44
|
+
* Renders inline below the target element when `show` is true.
|
|
45
|
+
* Tooltip text is rendered in `$muted` with dimColor for subtlety.
|
|
46
|
+
*/
|
|
47
|
+
export function Tooltip({ content, show = false, children }: TooltipProps): React.ReactElement {
|
|
48
|
+
return (
|
|
49
|
+
<Box flexDirection="column">
|
|
50
|
+
{children}
|
|
51
|
+
{show && (
|
|
52
|
+
<Box>
|
|
53
|
+
<Text color="$muted" dimColor>
|
|
54
|
+
{content}
|
|
55
|
+
</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
)}
|
|
58
|
+
</Box>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeView Component
|
|
3
|
+
*
|
|
4
|
+
* Expandable/collapsible hierarchical data display with keyboard navigation.
|
|
5
|
+
* Each node can have children, and the tree supports controlled or
|
|
6
|
+
* uncontrolled expansion state.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const data: TreeNode[] = [
|
|
11
|
+
* {
|
|
12
|
+
* id: "1",
|
|
13
|
+
* label: "Documents",
|
|
14
|
+
* children: [
|
|
15
|
+
* { id: "1.1", label: "README.md" },
|
|
16
|
+
* { id: "1.2", label: "notes.txt" },
|
|
17
|
+
* ],
|
|
18
|
+
* },
|
|
19
|
+
* { id: "2", label: "config.json" },
|
|
20
|
+
* ]
|
|
21
|
+
*
|
|
22
|
+
* <TreeView data={data} renderNode={(node) => <Text>{node.label}</Text>} />
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import React, { useCallback, useState } from "react"
|
|
26
|
+
import { useInput } from "@silvery/react/hooks/useInput"
|
|
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 TreeNode {
|
|
35
|
+
/** Unique identifier for this node */
|
|
36
|
+
id: string
|
|
37
|
+
/** Display label */
|
|
38
|
+
label: string
|
|
39
|
+
/** Child nodes (optional) */
|
|
40
|
+
children?: TreeNode[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TreeViewProps {
|
|
44
|
+
/** Hierarchical data to display */
|
|
45
|
+
data: TreeNode[]
|
|
46
|
+
/** Custom node renderer (default: renders label text) */
|
|
47
|
+
renderNode?: (node: TreeNode, depth: number) => React.ReactNode
|
|
48
|
+
/** Controlled: set of expanded node IDs */
|
|
49
|
+
expandedIds?: Set<string>
|
|
50
|
+
/** Called when expansion state changes */
|
|
51
|
+
onToggle?: (nodeId: string, expanded: boolean) => void
|
|
52
|
+
/** Whether nodes start expanded (default: false) */
|
|
53
|
+
defaultExpanded?: boolean
|
|
54
|
+
/** Whether this component captures input (default: true) */
|
|
55
|
+
isActive?: boolean
|
|
56
|
+
/** Indent per level in characters (default: 2) */
|
|
57
|
+
indent?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// Helpers
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
/** Flatten tree into visible list based on expansion state. */
|
|
65
|
+
function flattenTree(
|
|
66
|
+
nodes: TreeNode[],
|
|
67
|
+
expanded: Set<string>,
|
|
68
|
+
depth: number = 0,
|
|
69
|
+
): Array<{ node: TreeNode; depth: number }> {
|
|
70
|
+
const result: Array<{ node: TreeNode; depth: number }> = []
|
|
71
|
+
for (const node of nodes) {
|
|
72
|
+
result.push({ node, depth })
|
|
73
|
+
if (node.children && node.children.length > 0 && expanded.has(node.id)) {
|
|
74
|
+
result.push(...flattenTree(node.children, expanded, depth + 1))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Collect all node IDs in the tree (for defaultExpanded). */
|
|
81
|
+
function collectAllIds(nodes: TreeNode[]): Set<string> {
|
|
82
|
+
const ids = new Set<string>()
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
ids.add(node.id)
|
|
85
|
+
if (node.children) {
|
|
86
|
+
for (const id of collectAllIds(node.children)) {
|
|
87
|
+
ids.add(id)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return ids
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// Component
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Expandable/collapsible tree view.
|
|
100
|
+
*
|
|
101
|
+
* Navigate with Up/Down (or j/k), expand/collapse with Enter or Right/Left.
|
|
102
|
+
* Branch nodes show a triangle indicator (right = collapsed, down = expanded).
|
|
103
|
+
*/
|
|
104
|
+
export function TreeView({
|
|
105
|
+
data,
|
|
106
|
+
renderNode,
|
|
107
|
+
expandedIds: controlledExpanded,
|
|
108
|
+
onToggle,
|
|
109
|
+
defaultExpanded = false,
|
|
110
|
+
isActive = true,
|
|
111
|
+
indent = 2,
|
|
112
|
+
}: TreeViewProps): React.ReactElement {
|
|
113
|
+
const isControlled = controlledExpanded !== undefined
|
|
114
|
+
|
|
115
|
+
const [uncontrolledExpanded, setUncontrolledExpanded] = useState<Set<string>>(() =>
|
|
116
|
+
defaultExpanded ? collectAllIds(data) : new Set(),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const expanded = isControlled ? controlledExpanded : uncontrolledExpanded
|
|
120
|
+
const [cursorIndex, setCursorIndex] = useState(0)
|
|
121
|
+
|
|
122
|
+
const flatItems = flattenTree(data, expanded)
|
|
123
|
+
|
|
124
|
+
const toggleNode = useCallback(
|
|
125
|
+
(nodeId: string) => {
|
|
126
|
+
const isExpanded = expanded.has(nodeId)
|
|
127
|
+
if (!isControlled) {
|
|
128
|
+
setUncontrolledExpanded((prev) => {
|
|
129
|
+
const next = new Set(prev)
|
|
130
|
+
if (isExpanded) {
|
|
131
|
+
next.delete(nodeId)
|
|
132
|
+
} else {
|
|
133
|
+
next.add(nodeId)
|
|
134
|
+
}
|
|
135
|
+
return next
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
onToggle?.(nodeId, !isExpanded)
|
|
139
|
+
},
|
|
140
|
+
[expanded, isControlled, onToggle],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
useInput(
|
|
144
|
+
(input, key) => {
|
|
145
|
+
if (flatItems.length === 0) return
|
|
146
|
+
|
|
147
|
+
// Navigate up
|
|
148
|
+
if (key.upArrow || input === "k") {
|
|
149
|
+
setCursorIndex((prev) => Math.max(0, prev - 1))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Navigate down
|
|
154
|
+
if (key.downArrow || input === "j") {
|
|
155
|
+
setCursorIndex((prev) => Math.min(flatItems.length - 1, prev + 1))
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Expand / toggle
|
|
160
|
+
if (key.return || key.rightArrow) {
|
|
161
|
+
const item = flatItems[cursorIndex]
|
|
162
|
+
if (item?.node?.children && item.node.children.length > 0) {
|
|
163
|
+
if (!expanded.has(item.node.id)) {
|
|
164
|
+
toggleNode(item.node.id)
|
|
165
|
+
} else if (key.return) {
|
|
166
|
+
// Enter on already-expanded = collapse
|
|
167
|
+
toggleNode(item.node.id)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Collapse
|
|
174
|
+
if (key.leftArrow) {
|
|
175
|
+
const item = flatItems[cursorIndex]
|
|
176
|
+
if (item && expanded.has(item.node.id)) {
|
|
177
|
+
toggleNode(item.node.id)
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
{ isActive },
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if (flatItems.length === 0) {
|
|
186
|
+
return (
|
|
187
|
+
<Box>
|
|
188
|
+
<Text color="$disabledfg">No items</Text>
|
|
189
|
+
</Box>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<Box flexDirection="column">
|
|
195
|
+
{flatItems.map(({ node, depth }, i) => {
|
|
196
|
+
const isCursor = i === cursorIndex
|
|
197
|
+
const hasChildren = node.children && node.children.length > 0
|
|
198
|
+
const isExpanded = expanded.has(node.id)
|
|
199
|
+
const prefix = hasChildren ? (isExpanded ? "v " : "> ") : " "
|
|
200
|
+
const padding = " ".repeat(depth * indent)
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<Text key={node.id} inverse={isCursor}>
|
|
204
|
+
{padding}
|
|
205
|
+
<Text color={hasChildren ? "$primary" : "$fg"}>{prefix}</Text>
|
|
206
|
+
{renderNode ? renderNode(node, depth) : <Text>{node.label}</Text>}
|
|
207
|
+
</Text>
|
|
208
|
+
)
|
|
209
|
+
})}
|
|
210
|
+
</Box>
|
|
211
|
+
)
|
|
212
|
+
}
|