@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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skeleton Component
|
|
3
|
+
*
|
|
4
|
+
* Loading placeholder with configurable dimensions and shape.
|
|
5
|
+
* Renders a block of placeholder characters to indicate content
|
|
6
|
+
* that is loading or not yet available.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <Skeleton width={20} />
|
|
11
|
+
* <Skeleton width={30} height={3} />
|
|
12
|
+
* <Skeleton width={10} shape="circle" />
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import React from "react"
|
|
16
|
+
import { Box } from "@silvery/react/components/Box"
|
|
17
|
+
import { Text } from "@silvery/react/components/Text"
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Types
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
export interface SkeletonProps {
|
|
24
|
+
/** Width in columns (default: 20) */
|
|
25
|
+
width?: number
|
|
26
|
+
/** Height in rows (default: 1) */
|
|
27
|
+
height?: number
|
|
28
|
+
/** Placeholder character (default: "░") */
|
|
29
|
+
char?: string
|
|
30
|
+
/** Shape hint: "line" for single-line, "block" for multi-line (default: auto from height) */
|
|
31
|
+
shape?: "line" | "block" | "circle"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Constants
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
const DEFAULT_WIDTH = 20
|
|
39
|
+
const DEFAULT_CHAR = "░"
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Component
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Loading placeholder skeleton.
|
|
47
|
+
*
|
|
48
|
+
* Renders a dimmed block of placeholder characters. Use `width` and `height`
|
|
49
|
+
* to match the expected content dimensions. The `circle` shape renders
|
|
50
|
+
* a shorter, centered row for avatar-style placeholders.
|
|
51
|
+
*/
|
|
52
|
+
export function Skeleton({
|
|
53
|
+
width = DEFAULT_WIDTH,
|
|
54
|
+
height: heightProp,
|
|
55
|
+
char = DEFAULT_CHAR,
|
|
56
|
+
shape,
|
|
57
|
+
}: SkeletonProps): React.ReactElement {
|
|
58
|
+
const resolvedShape = shape ?? (heightProp && heightProp > 1 ? "block" : "line")
|
|
59
|
+
const height = heightProp ?? (resolvedShape === "circle" ? 1 : 1)
|
|
60
|
+
|
|
61
|
+
if (resolvedShape === "circle") {
|
|
62
|
+
// Render a centered shorter line to suggest a circular avatar
|
|
63
|
+
const circleWidth = Math.min(width, 6)
|
|
64
|
+
const pad = Math.max(0, Math.floor((width - circleWidth) / 2))
|
|
65
|
+
return (
|
|
66
|
+
<Box>
|
|
67
|
+
<Text color="$muted">
|
|
68
|
+
{" ".repeat(pad)}
|
|
69
|
+
{char.repeat(circleWidth)}
|
|
70
|
+
</Text>
|
|
71
|
+
</Box>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const line = char.repeat(width)
|
|
76
|
+
const rows = Array.from({ length: height }, (_, i) => i)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Box flexDirection="column">
|
|
80
|
+
{rows.map((i) => (
|
|
81
|
+
<Text key={i} color="$muted">
|
|
82
|
+
{line}
|
|
83
|
+
</Text>
|
|
84
|
+
))}
|
|
85
|
+
</Box>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spinner Component
|
|
3
|
+
*
|
|
4
|
+
* An animated loading spinner with multiple built-in styles.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <Spinner />
|
|
9
|
+
* <Spinner type="arc" label="Loading..." />
|
|
10
|
+
* <Spinner type="bounce" interval={120} />
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import React, { useEffect, useState } from "react"
|
|
14
|
+
import { Text } from "@silvery/react/components/Text"
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export interface SpinnerProps {
|
|
21
|
+
/** Spinner style preset */
|
|
22
|
+
type?: "dots" | "line" | "arc" | "bounce"
|
|
23
|
+
/** Label text shown after spinner */
|
|
24
|
+
label?: string
|
|
25
|
+
/** Animation interval in ms (default: 80) */
|
|
26
|
+
interval?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Frame Sequences
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
const FRAMES: Record<NonNullable<SpinnerProps["type"]>, readonly string[]> = {
|
|
34
|
+
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
35
|
+
line: ["|", "/", "—", "\\"],
|
|
36
|
+
arc: ["◜", "◠", "◝", "◞", "◡", "◟"],
|
|
37
|
+
bounce: ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// Component
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
export function Spinner({ type = "dots", label, interval = 80 }: SpinnerProps): React.ReactElement {
|
|
45
|
+
const [frameIndex, setFrameIndex] = useState(0)
|
|
46
|
+
const frames = FRAMES[type]
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const timer = setInterval(() => {
|
|
50
|
+
setFrameIndex((prev) => (prev + 1) % frames.length)
|
|
51
|
+
}, interval)
|
|
52
|
+
|
|
53
|
+
return () => clearInterval(timer)
|
|
54
|
+
}, [frames.length, interval])
|
|
55
|
+
|
|
56
|
+
const frame = frames[frameIndex % frames.length]!
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Text>
|
|
60
|
+
{frame}
|
|
61
|
+
{label ? ` ${label}` : ""}
|
|
62
|
+
</Text>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SplitView - Recursive binary-tree pane tiling component.
|
|
3
|
+
*
|
|
4
|
+
* Renders a layout tree of split panes using flexbox. Each leaf renders
|
|
5
|
+
* via renderPane(id). Splits divide space according to ratio (0-1,
|
|
6
|
+
* proportion given to first child).
|
|
7
|
+
*
|
|
8
|
+
* Horizontal splits use flexDirection="row", vertical splits use
|
|
9
|
+
* flexDirection="column". Each pane gets a border with optional title
|
|
10
|
+
* and focus highlight.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React from "react"
|
|
14
|
+
import type { LayoutNode } from "@silvery/term/pane-manager"
|
|
15
|
+
import { Box } from "@silvery/react/components/Box"
|
|
16
|
+
import { Text } from "@silvery/react/components/Text"
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export type { LayoutNode }
|
|
23
|
+
|
|
24
|
+
export interface SplitViewProps {
|
|
25
|
+
/** Layout tree describing the split arrangement */
|
|
26
|
+
layout: LayoutNode
|
|
27
|
+
/** Render function for each leaf pane */
|
|
28
|
+
renderPane: (id: string) => React.ReactNode
|
|
29
|
+
/** Optional: ID of the focused pane (for border highlighting) */
|
|
30
|
+
focusedPaneId?: string
|
|
31
|
+
/** Optional: show borders around panes (default: true) */
|
|
32
|
+
showBorders?: boolean
|
|
33
|
+
/** Optional: border style for focused pane */
|
|
34
|
+
focusedBorderColor?: string
|
|
35
|
+
/** Optional: border style for unfocused panes */
|
|
36
|
+
unfocusedBorderColor?: string
|
|
37
|
+
/** Optional: render pane title in border */
|
|
38
|
+
renderPaneTitle?: (id: string) => string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Constants
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
const MIN_PANE_WIDTH = 20
|
|
46
|
+
const MIN_PANE_HEIGHT = 5
|
|
47
|
+
const DEFAULT_FOCUSED_COLOR = "green"
|
|
48
|
+
const DEFAULT_UNFOCUSED_COLOR = "gray"
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Component
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* SplitView renders a binary tree of panes.
|
|
56
|
+
*
|
|
57
|
+
* Each leaf renders via `renderPane(id)`. Splits divide space
|
|
58
|
+
* according to `ratio` (0-1, proportion given to first child).
|
|
59
|
+
*/
|
|
60
|
+
export function SplitView(props: SplitViewProps): React.ReactElement {
|
|
61
|
+
const {
|
|
62
|
+
layout,
|
|
63
|
+
renderPane,
|
|
64
|
+
focusedPaneId,
|
|
65
|
+
showBorders = true,
|
|
66
|
+
focusedBorderColor = DEFAULT_FOCUSED_COLOR,
|
|
67
|
+
unfocusedBorderColor = DEFAULT_UNFOCUSED_COLOR,
|
|
68
|
+
renderPaneTitle,
|
|
69
|
+
} = props
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Box flexGrow={1} flexDirection="column">
|
|
73
|
+
<LayoutNodeView
|
|
74
|
+
node={layout}
|
|
75
|
+
renderPane={renderPane}
|
|
76
|
+
focusedPaneId={focusedPaneId}
|
|
77
|
+
showBorders={showBorders}
|
|
78
|
+
focusedBorderColor={focusedBorderColor}
|
|
79
|
+
unfocusedBorderColor={unfocusedBorderColor}
|
|
80
|
+
renderPaneTitle={renderPaneTitle}
|
|
81
|
+
/>
|
|
82
|
+
</Box>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Internal Components
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
interface LayoutNodeViewProps {
|
|
91
|
+
node: LayoutNode
|
|
92
|
+
renderPane: (id: string) => React.ReactNode
|
|
93
|
+
focusedPaneId?: string
|
|
94
|
+
showBorders: boolean
|
|
95
|
+
focusedBorderColor: string
|
|
96
|
+
unfocusedBorderColor: string
|
|
97
|
+
renderPaneTitle?: (id: string) => string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function LayoutNodeView(props: LayoutNodeViewProps): React.ReactElement {
|
|
101
|
+
const { node, renderPane, focusedPaneId, showBorders, focusedBorderColor, unfocusedBorderColor, renderPaneTitle } =
|
|
102
|
+
props
|
|
103
|
+
|
|
104
|
+
if (node.type === "leaf") {
|
|
105
|
+
return (
|
|
106
|
+
<LeafPane
|
|
107
|
+
id={node.id}
|
|
108
|
+
renderPane={renderPane}
|
|
109
|
+
isFocused={focusedPaneId === node.id}
|
|
110
|
+
showBorders={showBorders}
|
|
111
|
+
focusedBorderColor={focusedBorderColor}
|
|
112
|
+
unfocusedBorderColor={unfocusedBorderColor}
|
|
113
|
+
title={renderPaneTitle?.(node.id)}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Split node: render two children with flex proportions
|
|
119
|
+
// Use integer flex values to express the ratio without floating point issues.
|
|
120
|
+
// ratio=0.5 => flexGrow 50:50, ratio=0.3 => flexGrow 30:70, etc.
|
|
121
|
+
const firstFlex = Math.round(node.ratio * 100)
|
|
122
|
+
const secondFlex = 100 - firstFlex
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Box flexGrow={1} flexDirection={node.direction === "horizontal" ? "row" : "column"}>
|
|
126
|
+
<Box
|
|
127
|
+
flexGrow={firstFlex}
|
|
128
|
+
flexShrink={1}
|
|
129
|
+
minWidth={node.direction === "horizontal" ? MIN_PANE_WIDTH : undefined}
|
|
130
|
+
minHeight={node.direction === "vertical" ? MIN_PANE_HEIGHT : undefined}
|
|
131
|
+
>
|
|
132
|
+
<LayoutNodeView
|
|
133
|
+
node={node.first}
|
|
134
|
+
renderPane={renderPane}
|
|
135
|
+
focusedPaneId={focusedPaneId}
|
|
136
|
+
showBorders={showBorders}
|
|
137
|
+
focusedBorderColor={focusedBorderColor}
|
|
138
|
+
unfocusedBorderColor={unfocusedBorderColor}
|
|
139
|
+
renderPaneTitle={renderPaneTitle}
|
|
140
|
+
/>
|
|
141
|
+
</Box>
|
|
142
|
+
<Box
|
|
143
|
+
flexGrow={secondFlex}
|
|
144
|
+
flexShrink={1}
|
|
145
|
+
minWidth={node.direction === "horizontal" ? MIN_PANE_WIDTH : undefined}
|
|
146
|
+
minHeight={node.direction === "vertical" ? MIN_PANE_HEIGHT : undefined}
|
|
147
|
+
>
|
|
148
|
+
<LayoutNodeView
|
|
149
|
+
node={node.second}
|
|
150
|
+
renderPane={renderPane}
|
|
151
|
+
focusedPaneId={focusedPaneId}
|
|
152
|
+
showBorders={showBorders}
|
|
153
|
+
focusedBorderColor={focusedBorderColor}
|
|
154
|
+
unfocusedBorderColor={unfocusedBorderColor}
|
|
155
|
+
renderPaneTitle={renderPaneTitle}
|
|
156
|
+
/>
|
|
157
|
+
</Box>
|
|
158
|
+
</Box>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface LeafPaneProps {
|
|
163
|
+
id: string
|
|
164
|
+
renderPane: (id: string) => React.ReactNode
|
|
165
|
+
isFocused: boolean
|
|
166
|
+
showBorders: boolean
|
|
167
|
+
focusedBorderColor: string
|
|
168
|
+
unfocusedBorderColor: string
|
|
169
|
+
title?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function LeafPane(props: LeafPaneProps): React.ReactElement {
|
|
173
|
+
const { id, renderPane, isFocused, showBorders, focusedBorderColor, unfocusedBorderColor, title } = props
|
|
174
|
+
|
|
175
|
+
if (!showBorders) {
|
|
176
|
+
return (
|
|
177
|
+
<Box flexGrow={1} testID={`pane-${id}`}>
|
|
178
|
+
{renderPane(id)}
|
|
179
|
+
</Box>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<Box
|
|
185
|
+
flexGrow={1}
|
|
186
|
+
borderStyle="single"
|
|
187
|
+
borderColor={isFocused ? focusedBorderColor : unfocusedBorderColor}
|
|
188
|
+
testID={`pane-${id}`}
|
|
189
|
+
flexDirection="column"
|
|
190
|
+
>
|
|
191
|
+
{title != null && (
|
|
192
|
+
<Box>
|
|
193
|
+
<Text color={isFocused ? focusedBorderColor : unfocusedBorderColor}>{title}</Text>
|
|
194
|
+
</Box>
|
|
195
|
+
)}
|
|
196
|
+
<Box flexGrow={1}>{renderPane(id)}</Box>
|
|
197
|
+
</Box>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table Component
|
|
3
|
+
*
|
|
4
|
+
* A data table with headers and column alignment.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <Table
|
|
9
|
+
* columns={[
|
|
10
|
+
* { header: "Name", key: "name" },
|
|
11
|
+
* { header: "Age", key: "age", align: "right" },
|
|
12
|
+
* ]}
|
|
13
|
+
* data={[
|
|
14
|
+
* { name: "Alice", age: 30 },
|
|
15
|
+
* { name: "Bob", age: 25 },
|
|
16
|
+
* ]}
|
|
17
|
+
* />
|
|
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 TableColumn {
|
|
29
|
+
header: string
|
|
30
|
+
/** Key to extract from data row, or index for array data */
|
|
31
|
+
key?: string
|
|
32
|
+
/** Column width (auto if omitted) */
|
|
33
|
+
width?: number
|
|
34
|
+
/** Text alignment */
|
|
35
|
+
align?: "left" | "right" | "center"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TableProps {
|
|
39
|
+
/** Column definitions */
|
|
40
|
+
columns: TableColumn[]
|
|
41
|
+
/** Data rows — array of objects or arrays */
|
|
42
|
+
data: Array<Record<string, unknown> | unknown[]>
|
|
43
|
+
/** Show header row (default: true) */
|
|
44
|
+
showHeader?: boolean
|
|
45
|
+
/** Border between columns (default: " │ ") */
|
|
46
|
+
separator?: string
|
|
47
|
+
/** Header style */
|
|
48
|
+
headerBold?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Helpers
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
function getCellValue(row: Record<string, unknown> | unknown[], col: TableColumn, colIndex: number): string {
|
|
56
|
+
if (Array.isArray(row)) {
|
|
57
|
+
const val = row[colIndex]
|
|
58
|
+
return val == null ? "" : String(val)
|
|
59
|
+
}
|
|
60
|
+
if (col.key) {
|
|
61
|
+
const val = row[col.key]
|
|
62
|
+
return val == null ? "" : String(val)
|
|
63
|
+
}
|
|
64
|
+
return ""
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function alignText(text: string, width: number, align: "left" | "right" | "center" = "left"): string {
|
|
68
|
+
if (text.length >= width) return text.slice(0, width)
|
|
69
|
+
|
|
70
|
+
const pad = width - text.length
|
|
71
|
+
|
|
72
|
+
switch (align) {
|
|
73
|
+
case "right":
|
|
74
|
+
return " ".repeat(pad) + text
|
|
75
|
+
case "center": {
|
|
76
|
+
const leftPad = Math.floor(pad / 2)
|
|
77
|
+
const rightPad = pad - leftPad
|
|
78
|
+
return " ".repeat(leftPad) + text + " ".repeat(rightPad)
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
return text + " ".repeat(pad)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Component
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
export function Table({
|
|
90
|
+
columns,
|
|
91
|
+
data,
|
|
92
|
+
showHeader = true,
|
|
93
|
+
separator = " │ ",
|
|
94
|
+
headerBold = true,
|
|
95
|
+
}: TableProps): React.ReactElement {
|
|
96
|
+
// Calculate column widths
|
|
97
|
+
const colWidths = columns.map((col, colIndex) => {
|
|
98
|
+
if (col.width) return col.width
|
|
99
|
+
|
|
100
|
+
let maxWidth = col.header.length
|
|
101
|
+
for (const row of data) {
|
|
102
|
+
const cellText = getCellValue(row, col, colIndex)
|
|
103
|
+
maxWidth = Math.max(maxWidth, cellText.length)
|
|
104
|
+
}
|
|
105
|
+
return maxWidth
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Build header row
|
|
109
|
+
const headerCells = columns.map((col, i) => alignText(col.header, colWidths[i]!, col.align))
|
|
110
|
+
const headerLine = headerCells.join(separator)
|
|
111
|
+
|
|
112
|
+
// Build separator line
|
|
113
|
+
const separatorLine = colWidths.map((w) => "─".repeat(w)).join(separator.replace(/[^│]/g, "─").replace(/│/g, "┼"))
|
|
114
|
+
|
|
115
|
+
// Build data rows
|
|
116
|
+
const dataRows = data.map((row) => {
|
|
117
|
+
const cells = columns.map((col, i) => {
|
|
118
|
+
const value = getCellValue(row, col, i)
|
|
119
|
+
return alignText(value, colWidths[i]!, col.align)
|
|
120
|
+
})
|
|
121
|
+
return cells.join(separator)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Box flexDirection="column">
|
|
126
|
+
{showHeader && (
|
|
127
|
+
<>
|
|
128
|
+
<Text bold={headerBold} dimColor>
|
|
129
|
+
{headerLine}
|
|
130
|
+
</Text>
|
|
131
|
+
<Text dimColor>{separatorLine}</Text>
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
{dataRows.map((row, i) => (
|
|
135
|
+
<Text key={i}>{row}</Text>
|
|
136
|
+
))}
|
|
137
|
+
</Box>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs Component
|
|
3
|
+
*
|
|
4
|
+
* Tab bar with keyboard navigation and panel content switching.
|
|
5
|
+
* Uses compound component pattern: Tabs > TabList + TabPanel.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <Tabs defaultValue="general">
|
|
10
|
+
* <TabList>
|
|
11
|
+
* <Tab value="general">General</Tab>
|
|
12
|
+
* <Tab value="advanced">Advanced</Tab>
|
|
13
|
+
* <Tab value="about">About</Tab>
|
|
14
|
+
* </TabList>
|
|
15
|
+
* <TabPanel value="general">
|
|
16
|
+
* <Text>General settings...</Text>
|
|
17
|
+
* </TabPanel>
|
|
18
|
+
* <TabPanel value="advanced">
|
|
19
|
+
* <Text>Advanced settings...</Text>
|
|
20
|
+
* </TabPanel>
|
|
21
|
+
* <TabPanel value="about">
|
|
22
|
+
* <Text>About this app...</Text>
|
|
23
|
+
* </TabPanel>
|
|
24
|
+
* </Tabs>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import React, { createContext, useCallback, useContext, useState } from "react"
|
|
28
|
+
import { useInput } from "@silvery/react/hooks/useInput"
|
|
29
|
+
import { Box } from "@silvery/react/components/Box"
|
|
30
|
+
import { Text } from "@silvery/react/components/Text"
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Types
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
export interface TabsProps {
|
|
37
|
+
/** Default active tab value (uncontrolled) */
|
|
38
|
+
defaultValue?: string
|
|
39
|
+
/** Controlled active tab value */
|
|
40
|
+
value?: string
|
|
41
|
+
/** Called when the active tab changes */
|
|
42
|
+
onChange?: (value: string) => void
|
|
43
|
+
/** Whether tab input is active (default: true) */
|
|
44
|
+
isActive?: boolean
|
|
45
|
+
/** Tab children (TabList + TabPanel components) */
|
|
46
|
+
children: React.ReactNode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TabListProps {
|
|
50
|
+
/** Tab children */
|
|
51
|
+
children: React.ReactNode
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TabProps {
|
|
55
|
+
/** Unique tab identifier */
|
|
56
|
+
value: string
|
|
57
|
+
/** Tab label children */
|
|
58
|
+
children: React.ReactNode
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TabPanelProps {
|
|
62
|
+
/** Tab value this panel corresponds to */
|
|
63
|
+
value: string
|
|
64
|
+
/** Panel content */
|
|
65
|
+
children: React.ReactNode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Context
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
interface TabsContextValue {
|
|
73
|
+
activeValue: string
|
|
74
|
+
setActiveValue: (value: string) => void
|
|
75
|
+
tabValues: string[]
|
|
76
|
+
registerTab: (value: string) => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const TabsContext = createContext<TabsContextValue>({
|
|
80
|
+
activeValue: "",
|
|
81
|
+
setActiveValue: () => {},
|
|
82
|
+
tabValues: [],
|
|
83
|
+
registerTab: () => {},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
function useTabsContext(): TabsContextValue {
|
|
87
|
+
return useContext(TabsContext)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// =============================================================================
|
|
91
|
+
// Components
|
|
92
|
+
// =============================================================================
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Root tabs container. Provides context for TabList, Tab, and TabPanel.
|
|
96
|
+
*
|
|
97
|
+
* Supports controlled (`value` + `onChange`) and uncontrolled (`defaultValue`) modes.
|
|
98
|
+
* Navigate tabs with Left/Right arrow keys when the TabList is active.
|
|
99
|
+
*/
|
|
100
|
+
export function Tabs({
|
|
101
|
+
defaultValue,
|
|
102
|
+
value: controlledValue,
|
|
103
|
+
onChange,
|
|
104
|
+
isActive = true,
|
|
105
|
+
children,
|
|
106
|
+
}: TabsProps): React.ReactElement {
|
|
107
|
+
const isControlled = controlledValue !== undefined
|
|
108
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "")
|
|
109
|
+
const [tabValues, setTabValues] = useState<string[]>([])
|
|
110
|
+
|
|
111
|
+
const activeValue = isControlled ? controlledValue : uncontrolledValue
|
|
112
|
+
|
|
113
|
+
const setActiveValue = useCallback(
|
|
114
|
+
(val: string) => {
|
|
115
|
+
if (!isControlled) setUncontrolledValue(val)
|
|
116
|
+
onChange?.(val)
|
|
117
|
+
},
|
|
118
|
+
[isControlled, onChange],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const registerTab = useCallback((val: string) => {
|
|
122
|
+
setTabValues((prev) => (prev.includes(val) ? prev : [...prev, val]))
|
|
123
|
+
}, [])
|
|
124
|
+
|
|
125
|
+
// Keyboard navigation between tabs
|
|
126
|
+
useInput(
|
|
127
|
+
(_input, key) => {
|
|
128
|
+
if (tabValues.length === 0) return
|
|
129
|
+
|
|
130
|
+
const currentIdx = tabValues.indexOf(activeValue)
|
|
131
|
+
if (currentIdx < 0) return
|
|
132
|
+
|
|
133
|
+
if (key.rightArrow || _input === "l") {
|
|
134
|
+
const next = (currentIdx + 1) % tabValues.length
|
|
135
|
+
setActiveValue(tabValues[next]!)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (key.leftArrow || _input === "h") {
|
|
140
|
+
const next = (currentIdx - 1 + tabValues.length) % tabValues.length
|
|
141
|
+
setActiveValue(tabValues[next]!)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{ isActive },
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<TabsContext.Provider value={{ activeValue, setActiveValue, tabValues, registerTab }}>
|
|
150
|
+
<Box flexDirection="column">{children}</Box>
|
|
151
|
+
</TabsContext.Provider>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Horizontal tab bar container.
|
|
157
|
+
*
|
|
158
|
+
* Renders Tab children in a horizontal row with gap spacing.
|
|
159
|
+
*/
|
|
160
|
+
export function TabList({ children }: TabListProps): React.ReactElement {
|
|
161
|
+
return (
|
|
162
|
+
<Box flexDirection="row" gap={1} borderBottom borderColor="$border">
|
|
163
|
+
{children}
|
|
164
|
+
</Box>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Individual tab trigger.
|
|
170
|
+
*
|
|
171
|
+
* Renders the tab label with active/inactive styling. Active tab is bold
|
|
172
|
+
* with `$primary` color; inactive tabs use `$muted`.
|
|
173
|
+
*/
|
|
174
|
+
export function Tab({ value, children }: TabProps): React.ReactElement {
|
|
175
|
+
const { activeValue, registerTab } = useTabsContext()
|
|
176
|
+
const isActive = activeValue === value
|
|
177
|
+
|
|
178
|
+
// Register this tab's value for keyboard navigation
|
|
179
|
+
React.useEffect(() => {
|
|
180
|
+
registerTab(value)
|
|
181
|
+
}, [value, registerTab])
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<Box>
|
|
185
|
+
<Text color={isActive ? "$primary" : "$muted"} bold={isActive} underline={isActive}>
|
|
186
|
+
{children}
|
|
187
|
+
</Text>
|
|
188
|
+
</Box>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Tab panel content container.
|
|
194
|
+
*
|
|
195
|
+
* Only renders its children when the corresponding tab is active.
|
|
196
|
+
*/
|
|
197
|
+
export function TabPanel({ value, children }: TabPanelProps): React.ReactElement | null {
|
|
198
|
+
const { activeValue } = useTabsContext()
|
|
199
|
+
|
|
200
|
+
if (activeValue !== value) return null
|
|
201
|
+
|
|
202
|
+
return <Box flexDirection="column">{children}</Box>
|
|
203
|
+
}
|