@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,222 @@
1
+ /**
2
+ * CLI ProgressBar - Determinate progress indicator with ETA
3
+ */
4
+
5
+ import chalk from "chalk"
6
+ import type { ProgressBarOptions } from "../types.js"
7
+ import { CURSOR_HIDE, CURSOR_SHOW, CURSOR_TO_START, CLEAR_LINE_END, write, isTTY, getTerminalWidth } from "./ansi"
8
+ import { calculateETA, formatETA, DEFAULT_ETA_BUFFER_SIZE, type ETASample } from "../utils/eta"
9
+
10
+ /** Default format string */
11
+ const DEFAULT_FORMAT = ":bar :percent | :current/:total | ETA: :eta"
12
+
13
+ /**
14
+ * ProgressBar class for CLI progress indication
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const bar = new ProgressBar({ total: 100 });
19
+ * bar.start();
20
+ * for (let i = 0; i <= 100; i++) {
21
+ * await doWork();
22
+ * bar.update(i);
23
+ * }
24
+ * bar.stop();
25
+ * ```
26
+ */
27
+ export class ProgressBar {
28
+ private total: number
29
+ private format: string
30
+ private width: number
31
+ private complete: string
32
+ private incomplete: string
33
+ private stream: NodeJS.WriteStream
34
+ private hideCursor: boolean
35
+ private phases: Record<string, string>
36
+
37
+ private current = 0
38
+ private phase: string | null = null
39
+ private startTime: number | null = null
40
+ private isActive = false
41
+
42
+ // ETA smoothing - track last N update times
43
+ private etaBuffer: ETASample[] = []
44
+
45
+ constructor(options: ProgressBarOptions = {}) {
46
+ this.total = options.total ?? 100
47
+ this.format = options.format ?? DEFAULT_FORMAT
48
+ this.width = options.width ?? 40
49
+ this.complete = options.complete ?? "█"
50
+ this.incomplete = options.incomplete ?? "░"
51
+ this.stream = options.stream ?? process.stdout
52
+ this.hideCursor = options.hideCursor ?? true
53
+ this.phases = options.phases ?? {}
54
+ }
55
+
56
+ /**
57
+ * Start the progress bar
58
+ */
59
+ start(initialValue = 0, initialTotal?: number): this {
60
+ if (initialTotal !== undefined) {
61
+ this.total = initialTotal
62
+ }
63
+
64
+ this.current = initialValue
65
+ this.startTime = Date.now()
66
+ this.isActive = true
67
+ this.etaBuffer = [{ time: this.startTime, value: initialValue }]
68
+
69
+ if (this.hideCursor && isTTY(this.stream)) {
70
+ write(CURSOR_HIDE, this.stream)
71
+ }
72
+
73
+ this.render()
74
+ return this
75
+ }
76
+
77
+ /**
78
+ * Update progress value
79
+ */
80
+ update(value: number, tokens?: Record<string, string | number>): this {
81
+ this.current = Math.min(value, this.total)
82
+
83
+ // Update ETA buffer
84
+ const now = Date.now()
85
+ this.etaBuffer.push({ time: now, value: this.current })
86
+ if (this.etaBuffer.length > DEFAULT_ETA_BUFFER_SIZE) {
87
+ this.etaBuffer.shift()
88
+ }
89
+
90
+ if (this.isActive) {
91
+ this.render(tokens)
92
+ }
93
+
94
+ return this
95
+ }
96
+
97
+ /**
98
+ * Increment progress by amount (default: 1)
99
+ */
100
+ increment(amount = 1, tokens?: Record<string, string | number>): this {
101
+ return this.update(this.current + amount, tokens)
102
+ }
103
+
104
+ /**
105
+ * Set the current phase (for multi-phase progress)
106
+ */
107
+ setPhase(phaseName: string, options?: { current?: number; total?: number }): this {
108
+ this.phase = phaseName
109
+
110
+ if (options?.total !== undefined) {
111
+ this.total = options.total
112
+ }
113
+ if (options?.current !== undefined) {
114
+ this.current = options.current
115
+ // Reset ETA buffer on phase change
116
+ this.etaBuffer = [{ time: Date.now(), value: this.current }]
117
+ }
118
+
119
+ if (this.isActive) {
120
+ this.render()
121
+ }
122
+
123
+ return this
124
+ }
125
+
126
+ /**
127
+ * Stop the progress bar
128
+ */
129
+ stop(clear = false): this {
130
+ if (!this.isActive) {
131
+ return this
132
+ }
133
+
134
+ this.isActive = false
135
+
136
+ if (clear && isTTY(this.stream)) {
137
+ write(`${CURSOR_TO_START}${CLEAR_LINE_END}`, this.stream)
138
+ } else {
139
+ write("\n", this.stream)
140
+ }
141
+
142
+ if (this.hideCursor && isTTY(this.stream)) {
143
+ write(CURSOR_SHOW, this.stream)
144
+ }
145
+
146
+ return this
147
+ }
148
+
149
+ /** Get ETA in seconds using smoothed rate */
150
+ private getETASeconds(): number | null {
151
+ return calculateETA(this.etaBuffer, this.current, this.total)
152
+ }
153
+
154
+ /**
155
+ * Render the progress bar
156
+ */
157
+ private render(tokens?: Record<string, string | number>): void {
158
+ const percent = this.total > 0 ? this.current / this.total : 0
159
+ const eta = this.getETASeconds()
160
+
161
+ // Build the bar
162
+ const completeLength = Math.round(this.width * percent)
163
+ const incompleteLength = this.width - completeLength
164
+ const bar = this.complete.repeat(completeLength) + this.incomplete.repeat(incompleteLength)
165
+
166
+ // Get phase display name
167
+ const phaseDisplay = this.phase ? (this.phases[this.phase] ?? this.phase) : ""
168
+
169
+ // Calculate rate
170
+ const elapsed = this.startTime ? (Date.now() - this.startTime) / 1000 : 0
171
+ const rate = elapsed > 0 ? this.current / elapsed : 0
172
+
173
+ // Replace tokens in format string
174
+ let output = this.format
175
+ .replace(":bar", chalk.cyan(bar))
176
+ .replace(":percent", `${Math.round(percent * 100)}%`.padStart(4))
177
+ .replace(":current", String(this.current))
178
+ .replace(":total", String(this.total))
179
+ .replace(":eta", formatETA(eta))
180
+ .replace(":elapsed", formatETA(elapsed))
181
+ .replace(":rate", rate.toFixed(1))
182
+ .replace(":phase", chalk.dim(phaseDisplay))
183
+
184
+ // Replace custom tokens
185
+ if (tokens) {
186
+ for (const [key, value] of Object.entries(tokens)) {
187
+ output = output.replace(`:${key}`, String(value))
188
+ }
189
+ }
190
+
191
+ // Truncate to terminal width
192
+ const termWidth = getTerminalWidth(this.stream)
193
+ if (output.length > termWidth) {
194
+ output = output.slice(0, termWidth - 1)
195
+ }
196
+
197
+ if (isTTY(this.stream)) {
198
+ write(`${CURSOR_TO_START}${output}${CLEAR_LINE_END}`, this.stream)
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Get current progress ratio (0-1)
204
+ */
205
+ get ratio(): number {
206
+ return this.total > 0 ? this.current / this.total : 0
207
+ }
208
+
209
+ /**
210
+ * Get current progress percentage (0-100)
211
+ */
212
+ get percentage(): number {
213
+ return Math.round(this.ratio * 100)
214
+ }
215
+
216
+ /**
217
+ * Dispose the progress bar (calls stop)
218
+ */
219
+ [Symbol.dispose](): void {
220
+ this.stop()
221
+ }
222
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * CLI Spinner - Animated indeterminate progress indicator
3
+ */
4
+
5
+ import chalk from "chalk"
6
+ import type { SpinnerOptions, SpinnerStyle } from "../types.js"
7
+ import { CURSOR_HIDE, CURSOR_SHOW, CURSOR_TO_START, CLEAR_LINE_END, write, isTTY } from "./ansi"
8
+
9
+ /** Spinner animation frames by style */
10
+ export const SPINNER_FRAMES: Record<SpinnerStyle, string[]> = {
11
+ dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
12
+ line: ["-", "\\", "|", "/"],
13
+ arc: ["◜", "◠", "◝", "◞", "◡", "◟"],
14
+ bounce: ["⠁", "⠂", "⠄", "⠂"],
15
+ pulse: ["█", "▓", "▒", "░", "▒", "▓"],
16
+ }
17
+
18
+ /** Default intervals for each style (ms) */
19
+ export const SPINNER_INTERVALS: Record<SpinnerStyle, number> = {
20
+ dots: 80,
21
+ line: 120,
22
+ arc: 100,
23
+ bounce: 120,
24
+ pulse: 100,
25
+ }
26
+
27
+ /**
28
+ * Spinner class for CLI progress indication
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const spinner = new Spinner("Loading...");
33
+ * spinner.start();
34
+ * await doWork();
35
+ * spinner.succeed("Done!");
36
+ * ```
37
+ */
38
+ export class Spinner {
39
+ private text: string
40
+ private style: SpinnerStyle
41
+ private color: string
42
+ private stream: NodeJS.WriteStream
43
+ private hideCursor: boolean
44
+ private interval: number
45
+
46
+ private frameIndex = 0
47
+ private timer: ReturnType<typeof setInterval> | null = null
48
+ private isSpinning = false
49
+
50
+ constructor(textOrOptions?: string | SpinnerOptions) {
51
+ const options: SpinnerOptions = typeof textOrOptions === "string" ? { text: textOrOptions } : (textOrOptions ?? {})
52
+
53
+ this.text = options.text ?? ""
54
+ this.style = options.style ?? "dots"
55
+ this.color = options.color ?? "cyan"
56
+ this.stream = options.stream ?? process.stdout
57
+ this.hideCursor = options.hideCursor ?? true
58
+ this.interval = options.interval ?? SPINNER_INTERVALS[this.style]
59
+ }
60
+
61
+ /** Get current spinner text */
62
+ get currentText(): string {
63
+ return this.text
64
+ }
65
+
66
+ /** Set spinner text (updates immediately if spinning) */
67
+ set currentText(value: string) {
68
+ this.text = value
69
+ if (this.isSpinning) {
70
+ this.render()
71
+ }
72
+ }
73
+
74
+ /** Check if spinner is currently active */
75
+ get spinning(): boolean {
76
+ return this.isSpinning
77
+ }
78
+
79
+ /**
80
+ * Start the spinner animation
81
+ */
82
+ start(text?: string): this {
83
+ if (text !== undefined) {
84
+ this.text = text
85
+ }
86
+
87
+ if (this.isSpinning) {
88
+ return this
89
+ }
90
+
91
+ this.isSpinning = true
92
+ this.frameIndex = 0
93
+
94
+ if (this.hideCursor && isTTY(this.stream)) {
95
+ write(CURSOR_HIDE, this.stream)
96
+ }
97
+
98
+ this.render()
99
+ this.timer = setInterval(() => {
100
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES[this.style].length
101
+ this.render()
102
+ }, this.interval)
103
+
104
+ return this
105
+ }
106
+
107
+ /**
108
+ * Stop the spinner
109
+ */
110
+ stop(): this {
111
+ if (!this.isSpinning) {
112
+ return this
113
+ }
114
+
115
+ this.isSpinning = false
116
+
117
+ if (this.timer) {
118
+ clearInterval(this.timer)
119
+ this.timer = null
120
+ }
121
+
122
+ this.clear()
123
+
124
+ if (this.hideCursor && isTTY(this.stream)) {
125
+ write(CURSOR_SHOW, this.stream)
126
+ }
127
+
128
+ return this
129
+ }
130
+
131
+ /**
132
+ * Stop with success message (green checkmark)
133
+ */
134
+ succeed(text?: string): this {
135
+ return this.stopWithSymbol(chalk.green("✔"), text ?? this.text)
136
+ }
137
+
138
+ /**
139
+ * Stop with failure message (red X)
140
+ */
141
+ fail(text?: string): this {
142
+ return this.stopWithSymbol(chalk.red("✖"), text ?? this.text)
143
+ }
144
+
145
+ /**
146
+ * Stop with warning message (yellow warning)
147
+ */
148
+ warn(text?: string): this {
149
+ return this.stopWithSymbol(chalk.yellow("⚠"), text ?? this.text)
150
+ }
151
+
152
+ /**
153
+ * Stop with info message (blue info)
154
+ */
155
+ info(text?: string): this {
156
+ return this.stopWithSymbol(chalk.blue("ℹ"), text ?? this.text)
157
+ }
158
+
159
+ /**
160
+ * Clear the spinner line
161
+ */
162
+ clear(): this {
163
+ if (isTTY(this.stream)) {
164
+ write(`${CURSOR_TO_START}${CLEAR_LINE_END}`, this.stream)
165
+ }
166
+ return this
167
+ }
168
+
169
+ private render(): void {
170
+ const frame = SPINNER_FRAMES[this.style][this.frameIndex]
171
+ const colorFn = (chalk as unknown as Record<string, (s: string) => string>)[this.color]
172
+ const coloredFrame = colorFn ? colorFn(frame!) : frame!
173
+ const output = this.text ? `${coloredFrame} ${this.text}` : coloredFrame
174
+
175
+ if (isTTY(this.stream)) {
176
+ write(`${CURSOR_TO_START}${output}${CLEAR_LINE_END}`, this.stream)
177
+ }
178
+ }
179
+
180
+ private stopWithSymbol(symbol: string, text: string): this {
181
+ this.stop()
182
+ write(`${symbol} ${text}\n`, this.stream)
183
+ return this
184
+ }
185
+
186
+ /**
187
+ * Dispose the spinner (calls stop)
188
+ */
189
+ [Symbol.dispose](): void {
190
+ this.stop()
191
+ }
192
+
193
+ /**
194
+ * Static helper to quickly start a spinner
195
+ * Returns a stop function
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * const stop = Spinner.start("Loading...");
200
+ * await doWork();
201
+ * stop();
202
+ * ```
203
+ */
204
+ static start(textOrOptions?: string | SpinnerOptions): () => void {
205
+ const spinner = new Spinner(textOrOptions)
206
+ spinner.start()
207
+ return () => spinner.stop()
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Callable spinner interface - call with text to show/update
213
+ *
214
+ * @example
215
+ * ```ts
216
+ * {
217
+ * using spinner = createSpinner({ style: "dots" });
218
+ * spinner("Loading..."); // Shows spinner with text
219
+ * spinner("Still loading..."); // Updates text
220
+ * } // Auto-stops via Symbol.dispose
221
+ * ```
222
+ */
223
+ export interface CallableSpinner extends Disposable {
224
+ /** Call with text to show/update the spinner */
225
+ (text: string): void
226
+ /** Stop the spinner */
227
+ stop(): void
228
+ /** Stop with success message (green checkmark) */
229
+ succeed(text?: string): void
230
+ /** Stop with failure message (red X) */
231
+ fail(text?: string): void
232
+ /** Stop with warning message (yellow warning) */
233
+ warn(text?: string): void
234
+ /** Stop with info message (blue info) */
235
+ info(text?: string): void
236
+ }
237
+
238
+ /**
239
+ * Create a callable, disposable spinner
240
+ *
241
+ * The spinner is lazy - it won't show anything until you call it with text.
242
+ * Use with `using` for automatic cleanup:
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * {
247
+ * using spinner = createSpinner({ style: "dots" });
248
+ * // Nothing visible yet
249
+ *
250
+ * spinner("Loading repo..."); // Now shows spinner
251
+ * spinner("Applying events..."); // Updates text
252
+ * } // Auto-stops via Symbol.dispose
253
+ * ```
254
+ */
255
+ export function createSpinner(options?: SpinnerOptions): CallableSpinner {
256
+ const spinner = new Spinner({ ...options, text: "" })
257
+
258
+ const callable = ((text: string) => {
259
+ // Always restart if not spinning (handles initial call and after succeed/fail/etc)
260
+ if (!spinner.spinning) {
261
+ spinner.start(text)
262
+ } else {
263
+ spinner.currentText = text
264
+ }
265
+ }) as CallableSpinner
266
+
267
+ callable.stop = () => spinner.stop()
268
+ callable.succeed = (text) => spinner.succeed(text)
269
+ callable.fail = (text) => spinner.fail(text)
270
+ callable.warn = (text) => spinner.warn(text)
271
+ callable.info = (text) => spinner.info(text)
272
+ callable[Symbol.dispose] = () => spinner.stop()
273
+
274
+ return callable
275
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Badge Component
3
+ *
4
+ * A small inline label for status display.
5
+ *
6
+ * Usage:
7
+ * ```tsx
8
+ * <Badge label="Active" variant="success" />
9
+ * <Badge label="Warning" variant="warning" />
10
+ * <Badge label="Custom" color="magenta" />
11
+ * ```
12
+ */
13
+ import React from "react"
14
+ import { Text } from "@silvery/react/components/Text"
15
+
16
+ // =============================================================================
17
+ // Types
18
+ // =============================================================================
19
+
20
+ export interface BadgeProps {
21
+ /** Badge text */
22
+ label: string
23
+ /** Color variant */
24
+ variant?: "default" | "primary" | "success" | "warning" | "error"
25
+ /** Custom color (overrides variant) */
26
+ color?: string
27
+ }
28
+
29
+ // =============================================================================
30
+ // Constants
31
+ // =============================================================================
32
+
33
+ const VARIANT_COLORS: Record<NonNullable<BadgeProps["variant"]>, string> = {
34
+ default: "$fg",
35
+ primary: "$primary",
36
+ success: "$success",
37
+ warning: "$warning",
38
+ error: "$error",
39
+ }
40
+
41
+ // =============================================================================
42
+ // Component
43
+ // =============================================================================
44
+
45
+ export function Badge({ label, variant = "default", color }: BadgeProps): React.ReactElement {
46
+ const resolvedColor = color ?? VARIANT_COLORS[variant]
47
+
48
+ return (
49
+ <Text color={resolvedColor} bold>
50
+ {" "}
51
+ {label}{" "}
52
+ </Text>
53
+ )
54
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Breadcrumb Component
3
+ *
4
+ * Navigation breadcrumb trail with configurable separators.
5
+ * Highlights the last item as the current/active page.
6
+ *
7
+ * Usage:
8
+ * ```tsx
9
+ * <Breadcrumb
10
+ * items={[
11
+ * { label: "Home" },
12
+ * { label: "Settings" },
13
+ * { label: "Profile" },
14
+ * ]}
15
+ * separator=">"
16
+ * />
17
+ * // Renders: Home > Settings > Profile
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 BreadcrumbItem {
29
+ /** Display label */
30
+ label: string
31
+ }
32
+
33
+ export interface BreadcrumbProps {
34
+ /** Breadcrumb items (left to right) */
35
+ items: BreadcrumbItem[]
36
+ /** Separator character between items (default: "/") */
37
+ separator?: string
38
+ }
39
+
40
+ // =============================================================================
41
+ // Component
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Horizontal breadcrumb trail.
46
+ *
47
+ * Renders items separated by a configurable separator character.
48
+ * The last item is rendered in bold `$fg` as the current location;
49
+ * preceding items are rendered in `$muted`.
50
+ */
51
+ export function Breadcrumb({ items, separator = "/" }: BreadcrumbProps): React.ReactElement {
52
+ if (items.length === 0) {
53
+ return <Box />
54
+ }
55
+
56
+ return (
57
+ <Box>
58
+ {items.map((item, i) => {
59
+ const isLast = i === items.length - 1
60
+
61
+ return (
62
+ <React.Fragment key={i}>
63
+ {i > 0 && <Text color="$disabledfg"> {separator} </Text>}
64
+ <Text color={isLast ? "$fg" : "$muted"} bold={isLast}>
65
+ {item.label}
66
+ </Text>
67
+ </React.Fragment>
68
+ )
69
+ })}
70
+ </Box>
71
+ )
72
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Button Component
3
+ *
4
+ * A focusable button control. Integrates with the silvery focus system
5
+ * and responds to Enter or Space key to activate.
6
+ *
7
+ * Usage:
8
+ * ```tsx
9
+ * <Button label="Save" onPress={() => save()} />
10
+ * <Button label="Cancel" onPress={() => close()} color="red" />
11
+ *
12
+ * // With explicit active control (bypasses focus system)
13
+ * <Button label="OK" onPress={confirm} isActive={hasFocus} />
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 ButtonProps {
27
+ /** Button label */
28
+ label: string
29
+ /** Called when activated (Enter or Space) */
30
+ onPress: () => void
31
+ /** Whether input is active (default: from focus system) */
32
+ isActive?: boolean
33
+ /** Test ID for focus system */
34
+ testID?: string
35
+ /** Button color */
36
+ color?: string
37
+ }
38
+
39
+ // =============================================================================
40
+ // Component
41
+ // =============================================================================
42
+
43
+ /**
44
+ * Focusable button control.
45
+ *
46
+ * Renders `[ label ]` with inverse styling when focused. Activates on
47
+ * Enter or Space key press.
48
+ */
49
+ export function Button({ label, onPress, isActive, testID, color }: ButtonProps): 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
+ if (key.return || (_input === " " && !key.ctrl && !key.meta && !key.shift)) {
58
+ onPress()
59
+ }
60
+ },
61
+ { isActive: active },
62
+ )
63
+
64
+ return (
65
+ <Box focusable testID={testID}>
66
+ <Text color={color} inverse={active}>
67
+ {"[ "}
68
+ {label}
69
+ {" ]"}
70
+ </Text>
71
+ </Box>
72
+ )
73
+ }