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