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