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