@vitavision/ui 0.6.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/dist/index.js ADDED
@@ -0,0 +1,1348 @@
1
+ import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, HelpCircle, Info, Loader2, Minus, Moon, Sun, SunMoon, XCircle } from "lucide-react";
2
+ import { createContext, isValidElement, use, useCallback, useId, useMemo, useState, useSyncExternalStore } from "react";
3
+ import * as RadixTooltip from "@radix-ui/react-tooltip";
4
+ import { clsx } from "clsx";
5
+ import { twMerge } from "tailwind-merge";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ import { Slot, Slottable } from "@radix-ui/react-slot";
8
+ import * as RadixDialog from "@radix-ui/react-dialog";
9
+ import * as RadixSelect from "@radix-ui/react-select";
10
+ import * as RadixSlider from "@radix-ui/react-slider";
11
+ import * as RadixCheckbox from "@radix-ui/react-checkbox";
12
+ import * as RadixSwitch from "@radix-ui/react-switch";
13
+ //#region src/theme.ts
14
+ /**
15
+ * The `localStorage` key a consumer's theme choice is kept under.
16
+ *
17
+ * Every function here takes it as an optional last argument and defaults to this constant,
18
+ * so a single app needs to pass nothing, while two apps sharing a browser profile -- or one
19
+ * app that wants a namespaced key -- can each choose their own without stepping on the
20
+ * other's stored preference.
21
+ */
22
+ const DEFAULT_THEME_STORAGE_KEY = "vitavision-theme";
23
+ const query = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
24
+ /**
25
+ * The stored preference.
26
+ *
27
+ * @param storageKey - The `localStorage` key. Defaults to `DEFAULT_THEME_STORAGE_KEY`.
28
+ * @returns The stored choice, or `system` when nothing valid is stored or storage cannot be
29
+ * read (private browsing, the server).
30
+ */
31
+ function readThemeChoice(storageKey = DEFAULT_THEME_STORAGE_KEY) {
32
+ try {
33
+ const stored = window.localStorage.getItem(storageKey);
34
+ if (stored === "light" || stored === "dark" || stored === "system") return stored;
35
+ } catch {}
36
+ return "system";
37
+ }
38
+ /**
39
+ * What `choice` actually resolves to right now.
40
+ *
41
+ * @param choice - The preference.
42
+ * @returns `choice` itself for `light`/`dark`; for `system`, the OS palette (`light` where
43
+ * it cannot be queried).
44
+ */
45
+ function resolveTheme(choice) {
46
+ if (choice !== "system") return choice;
47
+ return query()?.matches ? "dark" : "light";
48
+ }
49
+ function paint(choice) {
50
+ if (typeof document === "undefined") return;
51
+ document.documentElement.classList.toggle("dark", resolveTheme(choice) === "dark");
52
+ }
53
+ /**
54
+ * Store a preference and paint it (the `dark` class on `<html>`).
55
+ *
56
+ * @param choice - The new preference.
57
+ * @param storageKey - The `localStorage` key. Defaults to `DEFAULT_THEME_STORAGE_KEY`.
58
+ */
59
+ function setThemeChoice(choice, storageKey = DEFAULT_THEME_STORAGE_KEY) {
60
+ try {
61
+ window.localStorage.setItem(storageKey, choice);
62
+ } catch {}
63
+ paint(choice);
64
+ }
65
+ /**
66
+ * Paint the stored choice and keep following the OS while the choice is "system".
67
+ *
68
+ * @param storageKey - The `localStorage` key. Defaults to `DEFAULT_THEME_STORAGE_KEY`.
69
+ * @returns An unsubscribe, so a test can tear the listener down; a long-lived app never does.
70
+ */
71
+ function initTheme(storageKey = DEFAULT_THEME_STORAGE_KEY) {
72
+ paint(readThemeChoice(storageKey));
73
+ const media = query();
74
+ if (media === null) return () => {};
75
+ const onSystemChange = () => {
76
+ if (readThemeChoice(storageKey) === "system") paint("system");
77
+ };
78
+ media.addEventListener("change", onSystemChange);
79
+ return () => media.removeEventListener("change", onSystemChange);
80
+ }
81
+ //#endregion
82
+ //#region src/tone.ts
83
+ const TONE_VAR = {
84
+ signal: "var(--signal)",
85
+ normal: "var(--normal)",
86
+ defect: "var(--defect)",
87
+ warn: "var(--warn)",
88
+ muted: "var(--fg-subtle)"
89
+ };
90
+ /**
91
+ * The CSS paint value for a tone — usable directly as a `stroke`, `fill` or `color`. It is a
92
+ * `var(--token)` reference, so it follows the theme without a re-render.
93
+ *
94
+ * @param tone - The tone; `undefined` falls back.
95
+ * @param fallback - The tone used when `tone` is `undefined`. Defaults to `signal`.
96
+ * @returns A `var(--…)` CSS value.
97
+ */
98
+ function toneColor(tone, fallback = "signal") {
99
+ return TONE_VAR[tone ?? fallback];
100
+ }
101
+ //#endregion
102
+ //#region src/components/ui/cn.ts
103
+ /**
104
+ * Class composition, and the shared focus treatment.
105
+ *
106
+ * A naive primitive *appends* the caller's `className` to its own base string, so
107
+ * `<Button className="px-6">` produces `px-3 … px-6` and wins or loses on source order
108
+ * rather than on intent. `twMerge` resolves that by conflict group: the caller's `px-6`
109
+ * replaces the base `px-3` and leaves everything else alone.
110
+ */
111
+ /**
112
+ * Compose class names: `clsx` for the conditionals, then `tailwind-merge` so a later class
113
+ * replaces an earlier one of the same conflict group (the caller's `px-6` beats the base
114
+ * `px-3`). Every primitive merges its caller's `className` through this.
115
+ *
116
+ * @param inputs - Class values: strings, arrays, and `{ class: condition }` records.
117
+ * @returns One class string with conflicts resolved.
118
+ */
119
+ function cn(...inputs) {
120
+ return twMerge(clsx(inputs));
121
+ }
122
+ /**
123
+ * One focus treatment for every interactive element in the design system.
124
+ *
125
+ * `outline` rather than `ring` because an outline follows the element's own border-radius
126
+ * and needs no offset *colour* -- a ring offset has to know which surface it sits on, and
127
+ * these primitives are used on `ground`, `surface` and `raised` alike.
128
+ *
129
+ * `focus-visible` rather than `focus`, so a mouse click does not leave a ring behind.
130
+ */
131
+ const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal";
132
+ /**
133
+ * The focus treatment for controls whose own box is the hit target, where an offset ring
134
+ * would clip: the same outline as {@link focusRing}, drawn with no offset.
135
+ */
136
+ const focusRingInset = "focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-signal";
137
+ //#endregion
138
+ //#region src/components/ui/Tooltip.tsx
139
+ /**
140
+ * Shares one open delay (200 ms) across the tooltips below it. Mount it once near the root:
141
+ * `Tooltip` and `InfoHint` need it above them.
142
+ */
143
+ function TooltipProvider({ children }) {
144
+ return /* @__PURE__ */ jsx(RadixTooltip.Provider, {
145
+ delayDuration: 200,
146
+ children
147
+ });
148
+ }
149
+ /**
150
+ * A short explanation on hover or focus of its child, for text that helps but is not needed
151
+ * to operate the control (that belongs in a field's description).
152
+ *
153
+ * The child must be one focusable element that accepts a ref (Radix `asChild`). Radix
154
+ * exposes `data-state` (`closed`/`delayed-open`/`instant-open`) on it.
155
+ */
156
+ function Tooltip({ content, children }) {
157
+ return /* @__PURE__ */ jsxs(RadixTooltip.Root, { children: [/* @__PURE__ */ jsx(RadixTooltip.Trigger, {
158
+ asChild: true,
159
+ children
160
+ }), /* @__PURE__ */ jsx(RadixTooltip.Portal, { children: /* @__PURE__ */ jsxs(RadixTooltip.Content, {
161
+ sideOffset: 6,
162
+ collisionPadding: 8,
163
+ className: cn("z-50 max-w-xs rounded-control border border-line bg-overlay px-2.5 py-1.5", "text-xs leading-snug text-fg shadow-lg shadow-black/25"),
164
+ children: [content, /* @__PURE__ */ jsx(RadixTooltip.Arrow, { className: "fill-overlay" })]
165
+ }) })] });
166
+ }
167
+ /**
168
+ * The standard affordance: a quiet mark that yields the explanation on hover or focus.
169
+ *
170
+ * `icon` exists because the mark answers two different questions. The default `?` offers
171
+ * help with a control; `Info` offers *facts about the thing on screen* -- where a dataset
172
+ * came from, which method produced a measurement -- which the tone demotes here rather
173
+ * than spending a permanent line on. Reading them as the same affordance would be a small
174
+ * lie.
175
+ */
176
+ function InfoHint({ children, label = "More information", icon: Icon = HelpCircle }) {
177
+ return /* @__PURE__ */ jsx(Tooltip, {
178
+ content: children,
179
+ children: /* @__PURE__ */ jsx("button", {
180
+ type: "button",
181
+ "aria-label": label,
182
+ className: cn("rounded-full text-fg-subtle transition-colors hover:text-fg", focusRing),
183
+ children: /* @__PURE__ */ jsx(Icon, { className: "size-3.5" })
184
+ })
185
+ });
186
+ }
187
+ //#endregion
188
+ //#region src/components/ThemeToggle.tsx
189
+ const ORDER = [
190
+ "system",
191
+ "light",
192
+ "dark"
193
+ ];
194
+ const ICON = {
195
+ system: SunMoon,
196
+ light: Sun,
197
+ dark: Moon
198
+ };
199
+ const LABEL = {
200
+ system: "Theme: following the system",
201
+ light: "Theme: light",
202
+ dark: "Theme: dark"
203
+ };
204
+ /** Another tab (or window) changing the stored choice re-renders the toggle. */
205
+ function subscribe(onChange) {
206
+ window.addEventListener("storage", onChange);
207
+ return () => window.removeEventListener("storage", onChange);
208
+ }
209
+ /** What the server, and a hydrating client's first render, show: no stored choice read yet. */
210
+ const serverChoice = () => "system";
211
+ /**
212
+ * The palette switch: one button cycling system → light → dark, stored in `localStorage`
213
+ * under `storageKey` and painted as the `dark` class on `<html>`.
214
+ *
215
+ * Its `aria-label` (repeated in a tooltip) names the current state; it needs a
216
+ * `TooltipProvider` above it. The stored choice is read as an external store, so the server
217
+ * render and the hydrating first client render agree ("system") and the stored choice
218
+ * follows without a mismatch. The state is exposed as `data-theme-choice`.
219
+ */
220
+ function ThemeToggle({ storageKey = DEFAULT_THEME_STORAGE_KEY, className }) {
221
+ const stored = useSyncExternalStore(subscribe, () => readThemeChoice(storageKey), serverChoice);
222
+ const [chosen, setChosen] = useState(null);
223
+ const choice = chosen?.key === storageKey ? chosen.choice : stored;
224
+ const advance = () => {
225
+ const next = ORDER[(ORDER.indexOf(choice) + 1) % ORDER.length];
226
+ setChosen({
227
+ key: storageKey,
228
+ choice: next
229
+ });
230
+ setThemeChoice(next, storageKey);
231
+ };
232
+ const Icon = ICON[choice];
233
+ return /* @__PURE__ */ jsx(Tooltip, {
234
+ content: LABEL[choice],
235
+ children: /* @__PURE__ */ jsx("button", {
236
+ type: "button",
237
+ onClick: advance,
238
+ "aria-label": LABEL[choice],
239
+ "data-theme-choice": choice,
240
+ className: cn("rounded-control p-1.5 text-fg-muted transition-colors hover:bg-raised hover:text-fg", focusRing, className),
241
+ children: /* @__PURE__ */ jsx(Icon, { className: "size-4" })
242
+ })
243
+ });
244
+ }
245
+ //#endregion
246
+ //#region src/components/ui/Badge.tsx
247
+ const TONE_CLASSES = {
248
+ neutral: "bg-raised text-fg-muted ring-line",
249
+ normal: "bg-normal/12 text-normal ring-normal/25",
250
+ defect: "bg-defect/12 text-defect ring-defect/25",
251
+ unlabeled: "bg-raised text-fg-subtle ring-line",
252
+ warning: "bg-warn/12 text-warn ring-warn/25",
253
+ info: "bg-signal/12 text-signal ring-signal/25"
254
+ };
255
+ const DOT_CLASSES = {
256
+ neutral: "bg-fg-subtle",
257
+ normal: "bg-normal",
258
+ defect: "bg-defect",
259
+ unlabeled: "bg-line-strong",
260
+ warning: "bg-warn",
261
+ info: "bg-signal"
262
+ };
263
+ /**
264
+ * A short label in a tone: a verdict, a status, a count.
265
+ *
266
+ * The tone is exposed as `data-tone`, so a stylesheet or a test can read it without parsing
267
+ * class names.
268
+ */
269
+ function Badge({ tone = "neutral", className, children }) {
270
+ return /* @__PURE__ */ jsx("span", {
271
+ "data-tone": tone,
272
+ className: cn("inline-flex items-center gap-1 rounded-control px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset", TONE_CLASSES[tone], className),
273
+ children
274
+ });
275
+ }
276
+ /**
277
+ * A status as a dot and a word.
278
+ *
279
+ * The mapping from a domain status to a tone belongs to each caller -- only it knows
280
+ * whether "running" is good news -- but the *rendering* of the answer does not.
281
+ */
282
+ function StatusDot({ tone, className, children }) {
283
+ return /* @__PURE__ */ jsxs("span", {
284
+ "data-tone": tone,
285
+ className: cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className),
286
+ children: [/* @__PURE__ */ jsx("span", {
287
+ className: cn("size-1.5 shrink-0 rounded-full", DOT_CLASSES[tone]),
288
+ "aria-hidden": true
289
+ }), children]
290
+ });
291
+ }
292
+ /** Counts as a compact `12 normal · 9 defect` run of badges, skipping the zeroes. */
293
+ function CountRun({ counts, className }) {
294
+ const shown = counts.filter(([, value]) => value > 0);
295
+ if (shown.length === 0) return /* @__PURE__ */ jsx("span", {
296
+ className: cn("text-xs text-fg-subtle", className),
297
+ children: "empty"
298
+ });
299
+ return /* @__PURE__ */ jsx("span", {
300
+ className: cn("flex flex-wrap items-center gap-1.5", className),
301
+ children: shown.map(([name, value, tone]) => /* @__PURE__ */ jsxs(Badge, {
302
+ tone,
303
+ children: [
304
+ /* @__PURE__ */ jsx("span", {
305
+ className: "font-mono",
306
+ children: value
307
+ }),
308
+ " ",
309
+ name
310
+ ]
311
+ }, name))
312
+ });
313
+ }
314
+ //#endregion
315
+ //#region src/components/ui/Density.tsx
316
+ const DensityContext = createContext("comfortable");
317
+ /**
318
+ * The density in force here. Every primitive that has two spacings reads this.
319
+ *
320
+ * @returns The value of the nearest `DensityProvider`, or `comfortable` outside one.
321
+ */
322
+ function useDensity() {
323
+ return use(DensityContext);
324
+ }
325
+ /**
326
+ * Pick one of two values by the density in force — the primitives' own idiom.
327
+ *
328
+ * @param density - The density in force, from `useDensity()`.
329
+ * @param comfortable - The value for `comfortable`.
330
+ * @param compact - The value for `compact`.
331
+ * @returns `compact` when the density is compact, else `comfortable`.
332
+ */
333
+ function byDensity(density, comfortable, compact) {
334
+ return density === "compact" ? compact : comfortable;
335
+ }
336
+ /**
337
+ * Sets the density for a region: every primitive below it reads the value through
338
+ * `useDensity`. Nest providers to switch a sub-region back.
339
+ */
340
+ function DensityProvider({ value, children }) {
341
+ return /* @__PURE__ */ jsx(DensityContext, {
342
+ value,
343
+ children
344
+ });
345
+ }
346
+ //#endregion
347
+ //#region src/components/ui/Button.tsx
348
+ const VARIANT_CLASSES = {
349
+ primary: "bg-signal text-signal-fg hover:bg-signal-strong",
350
+ secondary: "bg-surface text-fg ring-1 ring-inset ring-line-strong hover:bg-raised",
351
+ ghost: "text-fg-muted hover:bg-raised hover:text-fg",
352
+ danger: "bg-defect/10 text-defect ring-1 ring-inset ring-defect/30 hover:bg-defect/15"
353
+ };
354
+ const SIZE_CLASSES = {
355
+ sm: "h-7 gap-1.5 px-2.5 text-xs",
356
+ md: "h-8 gap-2 px-3 text-sm"
357
+ };
358
+ /**
359
+ * The classes that make a control look like a button, for the one element that has to be
360
+ * something else. Prefer `Button` and `ButtonLink`; this exists so a third shape — a
361
+ * `<label>` wrapping a file input, say — does not have to copy the variant table.
362
+ *
363
+ * @param options - The variant (default `secondary`), the size (default `md`) and the
364
+ * caller's own classes, merged last.
365
+ * @returns The class string.
366
+ */
367
+ function buttonClasses({ variant = "secondary", size = "md", className } = {}) {
368
+ return cn("inline-flex shrink-0 items-center justify-center rounded-control font-medium transition-colors", "disabled:cursor-not-allowed disabled:opacity-45", SIZE_CLASSES[size], VARIANT_CLASSES[variant], focusRing, className);
369
+ }
370
+ function ButtonIcon({ icon }) {
371
+ return /* @__PURE__ */ jsx("span", {
372
+ className: "shrink-0 [&>svg]:size-3.5",
373
+ "aria-hidden": true,
374
+ children: icon
375
+ });
376
+ }
377
+ /**
378
+ * An action. `asChild` renders the button's look onto the single child element instead of a
379
+ * `<button>` — how a router's link becomes a button-shaped navigation without this package
380
+ * knowing about routers: `<Button asChild><Link to="/runs">Runs</Link></Button>`.
381
+ * For a plain URL, `ButtonLink` is the shorter spelling.
382
+ *
383
+ * Takes every `<button>` prop, `ref` included. State is exposed as `data-variant` and, while
384
+ * `loading`, `data-loading` (plus `aria-busy`).
385
+ */
386
+ function Button({ variant = "secondary", size, type = "button", loading = false, icon, asChild = false, className, children, disabled, ...rest }) {
387
+ const density = useDensity();
388
+ const resolved = size ?? byDensity(density, "md", "sm");
389
+ if (asChild) return /* @__PURE__ */ jsxs(Slot, {
390
+ ...rest,
391
+ "data-variant": variant,
392
+ className: buttonClasses({
393
+ variant,
394
+ size: resolved,
395
+ className
396
+ }),
397
+ children: [icon && /* @__PURE__ */ jsx(ButtonIcon, { icon }), /* @__PURE__ */ jsx(Slottable, { children })]
398
+ });
399
+ return /* @__PURE__ */ jsxs("button", {
400
+ type,
401
+ disabled: disabled || loading,
402
+ "aria-busy": loading || void 0,
403
+ "data-variant": variant,
404
+ "data-loading": loading ? "" : void 0,
405
+ ...rest,
406
+ className: buttonClasses({
407
+ variant,
408
+ size: resolved,
409
+ className
410
+ }),
411
+ children: [loading ? /* @__PURE__ */ jsx(Loader2, {
412
+ className: "size-3.5 shrink-0 animate-spin",
413
+ "aria-hidden": true
414
+ }) : icon && /* @__PURE__ */ jsx(ButtonIcon, { icon }), children]
415
+ });
416
+ }
417
+ /**
418
+ * A navigation that looks like a button: one `<a>`, styled by the same variants and density.
419
+ *
420
+ * The alternative every consumer reached for was `<Link><Button/></Link>` — a button inside
421
+ * an anchor, which is invalid HTML, gives a keyboard two tab stops for one action, and makes
422
+ * a screen reader announce a button that navigates. There is no `disabled` or `loading`:
423
+ * a link that cannot be followed should not be rendered as one, and a navigation has no
424
+ * pending state of its own.
425
+ *
426
+ * It renders a plain `<a href>`, and takes every `<a>` prop, `ref` included. For a router's
427
+ * link, pass it as the only child with `asChild` —
428
+ * `<ButtonLink asChild><Link to="/runs">Runs</Link></ButtonLink>` — so client-side
429
+ * navigation keeps working and this package still never imports a router.
430
+ */
431
+ function ButtonLink({ variant = "secondary", size, icon, asChild = false, className, children, ...rest }) {
432
+ const density = useDensity();
433
+ const resolved = size ?? byDensity(density, "md", "sm");
434
+ return /* @__PURE__ */ jsxs(asChild ? Slot : "a", {
435
+ ...rest,
436
+ "data-variant": variant,
437
+ className: buttonClasses({
438
+ variant,
439
+ size: resolved,
440
+ className
441
+ }),
442
+ children: [icon && /* @__PURE__ */ jsx(ButtonIcon, { icon }), /* @__PURE__ */ jsx(Slottable, { children })]
443
+ });
444
+ }
445
+ //#endregion
446
+ //#region src/components/ui/Dialog.tsx
447
+ /**
448
+ * Whether the body overflows its height cap, kept current as the dialog or its content
449
+ * resizes. A scrolling body has to be reachable by keyboard (so it can be scrolled without
450
+ * a pointer); a body that fits must not add a tab stop.
451
+ */
452
+ function useOverflowing() {
453
+ const [overflowing, setOverflowing] = useState(false);
454
+ return [useCallback((node) => {
455
+ if (node === null) return void 0;
456
+ const measure = () => setOverflowing(node.scrollHeight > node.clientHeight + 1);
457
+ measure();
458
+ if (typeof ResizeObserver === "undefined") return void 0;
459
+ const observer = new ResizeObserver(measure);
460
+ observer.observe(node);
461
+ if (node.firstElementChild) observer.observe(node.firstElementChild);
462
+ return () => observer.disconnect();
463
+ }, []), overflowing];
464
+ }
465
+ /**
466
+ * A modal: a title, a body the caller fills, and a footer it chooses.
467
+ *
468
+ * Controlled — the caller owns `open`. The body scrolls under a height cap so the footer
469
+ * stays on screen; while it overflows it is a keyboard tab stop (with the focus ring) and
470
+ * carries `data-overflowing`, so it can be scrolled without a pointer.
471
+ */
472
+ function Dialog({ open, onOpenChange, title, description, footer, className, children }) {
473
+ const [bodyRef, overflowing] = useOverflowing();
474
+ return /* @__PURE__ */ jsx(RadixDialog.Root, {
475
+ open,
476
+ onOpenChange,
477
+ children: /* @__PURE__ */ jsxs(RadixDialog.Portal, { children: [/* @__PURE__ */ jsx(RadixDialog.Overlay, { className: "fixed inset-0 z-50 bg-black/50 backdrop-blur-[1px]" }), /* @__PURE__ */ jsxs(RadixDialog.Content, {
478
+ className: cn("fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2", "flex max-h-[calc(100vh-4rem)] flex-col", "rounded-panel border border-line bg-overlay p-5 shadow-xl shadow-black/40", "focus:outline-none", className),
479
+ children: [
480
+ /* @__PURE__ */ jsx(RadixDialog.Title, {
481
+ className: "text-sm font-semibold tracking-tight text-fg",
482
+ children: title
483
+ }),
484
+ /* @__PURE__ */ jsx(RadixDialog.Description, {
485
+ className: cn("mt-2 text-sm leading-relaxed text-fg-muted", description === void 0 && "sr-only"),
486
+ children: description ?? title
487
+ }),
488
+ /* @__PURE__ */ jsx("div", {
489
+ ref: bodyRef,
490
+ tabIndex: overflowing ? 0 : void 0,
491
+ "data-overflowing": overflowing ? "" : void 0,
492
+ className: cn("min-h-0 overflow-y-auto", focusRingInset),
493
+ children: /* @__PURE__ */ jsx("div", { children })
494
+ }),
495
+ footer && /* @__PURE__ */ jsx("div", {
496
+ className: "mt-5 flex shrink-0 justify-end gap-2",
497
+ children: footer
498
+ })
499
+ ]
500
+ })] })
501
+ });
502
+ }
503
+ /**
504
+ * The destructive-action confirmation: a `Dialog` whose footer is Cancel and one confirm
505
+ * button. Say in `description` what will actually happen.
506
+ */
507
+ function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel, onConfirm, loading = false, disabled = false, destructive = false, className }) {
508
+ return /* @__PURE__ */ jsx(Dialog, {
509
+ open,
510
+ onOpenChange,
511
+ title,
512
+ description,
513
+ className,
514
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(RadixDialog.Close, {
515
+ asChild: true,
516
+ children: /* @__PURE__ */ jsx(Button, {
517
+ variant: "ghost",
518
+ children: "Cancel"
519
+ })
520
+ }), /* @__PURE__ */ jsx(Button, {
521
+ variant: destructive ? "danger" : "primary",
522
+ loading,
523
+ disabled,
524
+ onClick: onConfirm,
525
+ children: confirmLabel
526
+ })] })
527
+ });
528
+ }
529
+ /**
530
+ * The dialog's own dismiss, for a footer the caller builds: wrap a button in it with
531
+ * `asChild` — `<DialogClose asChild><Button>Done</Button></DialogClose>`.
532
+ */
533
+ const DialogClose = RadixDialog.Close;
534
+ //#endregion
535
+ //#region src/components/ui/Disclosure.tsx
536
+ /**
537
+ * Content folded away behind a summary line, on a native `<details>`, with a caret that
538
+ * rotates with the element's own `open` state (no JavaScript involved).
539
+ *
540
+ * Uncontrolled: `defaultOpen` sets the initial state and the element owns it after that.
541
+ */
542
+ function Disclosure({ summary, count, defaultOpen = false, className, children }) {
543
+ return /* @__PURE__ */ jsxs("details", {
544
+ open: defaultOpen,
545
+ className: cn("group", className),
546
+ children: [/* @__PURE__ */ jsxs("summary", {
547
+ className: cn("flex cursor-pointer items-center gap-1.5 rounded-control py-1 text-xs font-medium text-fg-muted", "transition-colors hover:text-fg", "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal"),
548
+ children: [
549
+ /* @__PURE__ */ jsx(ChevronRight, {
550
+ className: "size-3.5 shrink-0 transition-transform group-open:rotate-90",
551
+ "aria-hidden": true
552
+ }),
553
+ summary,
554
+ count !== void 0 && /* @__PURE__ */ jsx("span", {
555
+ className: "font-mono text-fg-subtle tabular-nums",
556
+ children: count
557
+ })
558
+ ]
559
+ }), /* @__PURE__ */ jsx("div", {
560
+ className: "pt-3",
561
+ children
562
+ })]
563
+ });
564
+ }
565
+ //#endregion
566
+ //#region src/components/ui/Feedback.tsx
567
+ const CALLOUT = {
568
+ info: {
569
+ classes: "border-signal/30 bg-signal/8 text-fg",
570
+ Icon: Info
571
+ },
572
+ warning: {
573
+ classes: "border-warn/30 bg-warn/8 text-fg",
574
+ Icon: AlertTriangle
575
+ },
576
+ error: {
577
+ classes: "border-defect/30 bg-defect/8 text-fg",
578
+ Icon: XCircle
579
+ },
580
+ success: {
581
+ classes: "border-normal/30 bg-normal/8 text-fg",
582
+ Icon: CheckCircle2
583
+ }
584
+ };
585
+ const ICON_TONE = {
586
+ info: "text-signal",
587
+ warning: "text-warn",
588
+ error: "text-defect",
589
+ success: "text-normal"
590
+ };
591
+ /**
592
+ * A boxed message with a tone icon, an optional title and optional actions.
593
+ *
594
+ * An `error` callout is a `role="alert"` (announced at once); the others are
595
+ * `role="status"`. The tone is exposed as `data-tone`.
596
+ */
597
+ function Callout({ tone = "info", title, actions, className, children }) {
598
+ const { classes, Icon } = CALLOUT[tone];
599
+ return /* @__PURE__ */ jsxs("div", {
600
+ role: tone === "error" ? "alert" : "status",
601
+ "data-tone": tone,
602
+ className: cn("flex items-start gap-2.5 rounded-control border px-3 py-2.5 text-sm leading-snug", classes, className),
603
+ children: [/* @__PURE__ */ jsx(Icon, {
604
+ className: cn("mt-0.5 size-4 shrink-0", ICON_TONE[tone]),
605
+ "aria-hidden": true
606
+ }), /* @__PURE__ */ jsxs("div", {
607
+ className: "flex min-w-0 flex-1 flex-col gap-1",
608
+ children: [
609
+ title && /* @__PURE__ */ jsx("p", {
610
+ className: "font-medium",
611
+ children: title
612
+ }),
613
+ /* @__PURE__ */ jsx("div", {
614
+ className: "min-w-0 text-fg-muted",
615
+ children
616
+ }),
617
+ actions && /* @__PURE__ */ jsx("div", {
618
+ className: "mt-1 flex flex-wrap items-center gap-2",
619
+ children: actions
620
+ })
621
+ ]
622
+ })]
623
+ });
624
+ }
625
+ /** The common error shape: an `error` `Callout` with only a message. */
626
+ function ErrorBox({ className, children }) {
627
+ return /* @__PURE__ */ jsx(Callout, {
628
+ tone: "error",
629
+ className,
630
+ children
631
+ });
632
+ }
633
+ /** An empty state: a sentence saying why there is nothing here, and the action that fixes it. */
634
+ function Empty({ action, className, children }) {
635
+ return /* @__PURE__ */ jsxs("div", {
636
+ className: cn("flex flex-col items-center gap-3 py-10 text-center", className),
637
+ children: [/* @__PURE__ */ jsx("p", {
638
+ className: "text-sm text-fg-muted",
639
+ children
640
+ }), action]
641
+ });
642
+ }
643
+ /** One placeholder bar for content on its way (`aria-hidden`; size it with `className`). */
644
+ function Skeleton({ className }) {
645
+ return /* @__PURE__ */ jsx("div", {
646
+ "aria-hidden": true,
647
+ className: cn("animate-pulse rounded-control bg-line", className)
648
+ });
649
+ }
650
+ /** A stand-in for a panel's worth of content, so the layout does not jump when it lands. */
651
+ function SkeletonRows({ rows = 3, className }) {
652
+ return /* @__PURE__ */ jsx("div", {
653
+ role: "status",
654
+ "aria-label": "Loading",
655
+ className: cn("flex flex-col gap-2", className),
656
+ children: Array.from({ length: rows }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: cn("h-4", index === rows - 1 ? "w-2/3" : "w-full") }, index))
657
+ });
658
+ }
659
+ /**
660
+ * A known fraction done, as a bar and (with `label`) a percentage line.
661
+ *
662
+ * A `role="progressbar"` with `aria-valuenow` in percent. Its accessible name is
663
+ * `aria-label` if given, else `label`, else "Progress" — pass one that names the job when
664
+ * a screen shows more than one bar.
665
+ */
666
+ function ProgressBar({ fraction, label, "aria-label": ariaLabel, className }) {
667
+ const percent = Math.round(Math.min(Math.max(fraction, 0), 1) * 100);
668
+ return /* @__PURE__ */ jsxs("div", {
669
+ className: cn("flex flex-col gap-1.5", className),
670
+ children: [/* @__PURE__ */ jsx("div", {
671
+ role: "progressbar",
672
+ "aria-label": ariaLabel ?? label ?? "Progress",
673
+ "aria-valuenow": percent,
674
+ "aria-valuemin": 0,
675
+ "aria-valuemax": 100,
676
+ "data-complete": percent === 100 ? "" : void 0,
677
+ className: "h-1.5 overflow-hidden rounded-full bg-line",
678
+ children: /* @__PURE__ */ jsx("div", {
679
+ className: "h-full rounded-full bg-signal transition-[width] duration-300",
680
+ style: { width: `${percent}%` }
681
+ })
682
+ }), label && /* @__PURE__ */ jsxs("span", {
683
+ className: "font-mono text-xs text-fg-muted tabular-nums",
684
+ children: [
685
+ percent,
686
+ "% · ",
687
+ label
688
+ ]
689
+ })]
690
+ });
691
+ }
692
+ //#endregion
693
+ //#region src/components/ui/Field.tsx
694
+ const FieldContext = createContext({
695
+ describedBy: void 0,
696
+ invalid: false
697
+ });
698
+ /**
699
+ * The `aria-describedby` and `aria-invalid` a control inside a `Field` should carry, merged
700
+ * with the control's own `aria-describedby`. Used by this package's controls; not exported
701
+ * from the package.
702
+ */
703
+ function useFieldDescription(own) {
704
+ const { describedBy, invalid } = use(FieldContext);
705
+ const ids = [own, describedBy].filter(Boolean).join(" ");
706
+ return {
707
+ "aria-describedby": ids === "" ? void 0 : ids,
708
+ "aria-invalid": invalid || void 0
709
+ };
710
+ }
711
+ /**
712
+ * A label, the control, and optionally a description, an error and a compact annotation.
713
+ *
714
+ * The label text and the annotation name the control (the `<label>` wraps them and the
715
+ * control); the description and the error are outside the label and wired into the
716
+ * control's `aria-describedby` instead (the error also sets `aria-invalid`): directly for
717
+ * this package's controls — `Input`, `NumberInput`, `Textarea`, `Select`, `Slider`,
718
+ * `SegmentedControl`, `Checkbox` — and, with `as="group"`, on the group itself. A control of your own inside a `label` field does not
719
+ * pick them up; give it `aria-describedby` yourself. State is exposed as `data-invalid` and
720
+ * `data-required`.
721
+ */
722
+ function Field({ label, description, error, required = false, annotation, as = "label", className, children }) {
723
+ const descriptionId = useId();
724
+ const errorId = useId();
725
+ const density = useDensity();
726
+ const hasError = error !== void 0 && error !== null && error !== false;
727
+ const describedBy = [description ? descriptionId : void 0, hasError ? errorId : void 0].filter(Boolean).join(" ") || void 0;
728
+ const control = useMemo(() => ({
729
+ describedBy: as === "label" ? describedBy : void 0,
730
+ invalid: hasError
731
+ }), [
732
+ as,
733
+ describedBy,
734
+ hasError
735
+ ]);
736
+ const gap = byDensity(density, "gap-1.5", "gap-1");
737
+ const labelled = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
738
+ className: "flex items-baseline gap-2",
739
+ children: [/* @__PURE__ */ jsxs("span", {
740
+ className: cn("font-medium text-fg", byDensity(density, "text-xs", "text-[11px]")),
741
+ children: [label, required && /* @__PURE__ */ jsx("span", {
742
+ className: "ml-0.5 text-defect",
743
+ title: "Required",
744
+ children: "*"
745
+ })]
746
+ }), annotation && /* @__PURE__ */ jsx("span", {
747
+ className: "ml-auto font-mono text-[11px] text-fg-subtle",
748
+ children: annotation
749
+ })]
750
+ }), /* @__PURE__ */ jsx(FieldContext, {
751
+ value: control,
752
+ children
753
+ })] });
754
+ return /* @__PURE__ */ jsxs("div", {
755
+ className: cn("flex min-w-0 flex-col", gap, className),
756
+ "data-invalid": hasError ? "" : void 0,
757
+ "data-required": required ? "" : void 0,
758
+ ...as === "group" ? {
759
+ role: "group",
760
+ "aria-label": label,
761
+ "aria-describedby": describedBy
762
+ } : {},
763
+ children: [
764
+ as === "label" ? /* @__PURE__ */ jsx("label", {
765
+ className: cn("flex min-w-0 flex-col", gap),
766
+ children: labelled
767
+ }) : labelled,
768
+ description && /* @__PURE__ */ jsx("span", {
769
+ id: descriptionId,
770
+ className: "text-xs leading-snug text-fg-muted",
771
+ children: description
772
+ }),
773
+ hasError && /* @__PURE__ */ jsx("span", {
774
+ id: errorId,
775
+ role: "alert",
776
+ className: "text-xs leading-snug text-defect",
777
+ children: error
778
+ })
779
+ ]
780
+ });
781
+ }
782
+ //#endregion
783
+ //#region src/components/ui/Input.tsx
784
+ /**
785
+ * The shared look of a text-like control — border, palette, placeholder, hover, disabled and
786
+ * focus — for a native control that is not one of `Input`, `NumberInput` or `Textarea`.
787
+ * Add the height with `useControlHeight()`.
788
+ */
789
+ const controlClasses = cn("w-full rounded-control border border-line-strong bg-raised px-2.5 text-sm text-fg", "placeholder:text-fg-subtle", "transition-colors hover:border-fg-subtle", "disabled:cursor-not-allowed disabled:opacity-50", focusRingInset);
790
+ /**
791
+ * The former name of `controlClasses`, kept as an alias for existing consumers.
792
+ *
793
+ * @deprecated Use `Input`, `NumberInput`, `Textarea` or `Select`, or `controlClasses`.
794
+ */
795
+ const inputClasses = controlClasses;
796
+ /**
797
+ * The control height in force — exported so an app's own native control can match.
798
+ *
799
+ * @returns A Tailwind height class: `h-8` comfortable, `h-7` compact.
800
+ */
801
+ function useControlHeight() {
802
+ return byDensity(useDensity(), "h-8", "h-7");
803
+ }
804
+ /**
805
+ * A single-line text input. Takes every `<input>` prop, `ref` included; inside a `Field` it
806
+ * is described by the field's description and error.
807
+ */
808
+ function Input({ className, "aria-describedby": describedBy, ...rest }) {
809
+ return /* @__PURE__ */ jsx("input", {
810
+ ...useFieldDescription(describedBy),
811
+ ...rest,
812
+ className: cn(controlClasses, useControlHeight(), className)
813
+ });
814
+ }
815
+ /**
816
+ * A number, with the schema's own bounds attached.
817
+ *
818
+ * `font-mono` because these are read as quantities and compared down a column. Takes every
819
+ * `<input>` prop, `ref` included (`type` is always `number`).
820
+ */
821
+ function NumberInput({ className, "aria-describedby": describedBy, ...rest }) {
822
+ return /* @__PURE__ */ jsx("input", {
823
+ ...useFieldDescription(describedBy),
824
+ ...rest,
825
+ type: "number",
826
+ inputMode: "decimal",
827
+ className: cn(controlClasses, useControlHeight(), "font-mono tabular-nums", className)
828
+ });
829
+ }
830
+ /**
831
+ * Multi-line text — set in mono, for JSON and lists. Takes every `<textarea>` prop, `ref`
832
+ * included.
833
+ */
834
+ function Textarea({ className, "aria-describedby": describedBy, ...rest }) {
835
+ return /* @__PURE__ */ jsx("textarea", {
836
+ ...useFieldDescription(describedBy),
837
+ ...rest,
838
+ className: cn(controlClasses, "py-1.5 font-mono", className)
839
+ });
840
+ }
841
+ //#endregion
842
+ //#region src/components/ui/Panel.tsx
843
+ /**
844
+ * A bordered surface with an optional header (a title and actions) above a padded body.
845
+ * The header and the padding follow the density in force.
846
+ */
847
+ function Panel({ title, actions, className, bodyClassName, children }) {
848
+ const density = useDensity();
849
+ return /* @__PURE__ */ jsxs("section", {
850
+ className: cn("rounded-panel border border-line bg-surface", className),
851
+ children: [(title || actions) && /* @__PURE__ */ jsxs("header", {
852
+ className: cn("flex items-center justify-between gap-4 border-b border-line", byDensity(density, "min-h-11 px-4 py-2.5", "min-h-8 px-2.5 py-1")),
853
+ children: [/* @__PURE__ */ jsx("h2", {
854
+ className: cn("truncate font-semibold text-fg", byDensity(density, "text-sm tracking-tight", "text-[11px] tracking-wider uppercase text-fg-muted")),
855
+ children: title
856
+ }), actions && /* @__PURE__ */ jsx("div", {
857
+ className: "flex shrink-0 items-center gap-1.5",
858
+ children: actions
859
+ })]
860
+ }), /* @__PURE__ */ jsx("div", {
861
+ className: cn(byDensity(density, "p-4", "p-2.5"), bodyClassName),
862
+ children
863
+ })]
864
+ });
865
+ }
866
+ /**
867
+ * A numbered step inside a form.
868
+ *
869
+ * The number is not decoration: it marks a genuine ordering, where each choice narrows the
870
+ * next. Do not reach for this where the parts are merely adjacent.
871
+ */
872
+ function Section({ step, title, hint, actions, className, children }) {
873
+ const density = useDensity();
874
+ return /* @__PURE__ */ jsxs("section", {
875
+ className: cn("flex flex-col", byDensity(density, "gap-3", "gap-1.5"), className),
876
+ children: [/* @__PURE__ */ jsxs("div", {
877
+ className: cn("flex items-baseline", byDensity(density, "gap-2.5", "gap-2")),
878
+ children: [
879
+ step !== void 0 && /* @__PURE__ */ jsx("span", {
880
+ className: "font-mono text-xs text-fg-subtle tabular-nums",
881
+ "aria-hidden": true,
882
+ children: String(step).padStart(2, "0")
883
+ }),
884
+ /* @__PURE__ */ jsx("h3", {
885
+ className: "text-sm font-semibold tracking-tight text-fg",
886
+ children: title
887
+ }),
888
+ hint && /* @__PURE__ */ jsx("p", {
889
+ className: "text-xs text-fg-muted",
890
+ children: hint
891
+ }),
892
+ actions && /* @__PURE__ */ jsx("div", {
893
+ className: "ml-auto flex items-center gap-2",
894
+ children: actions
895
+ })
896
+ ]
897
+ }), children]
898
+ });
899
+ }
900
+ const BACK_CLASSES = "w-fit text-xs text-fg-muted transition-colors hover:text-signal";
901
+ /**
902
+ * The top of a screen: an optional back link, the `<h1>` title with actions beside it, and
903
+ * a line of meta facts below. Links go through `back`, never a router import.
904
+ */
905
+ function PageHeader({ title, meta, actions, back, className }) {
906
+ return /* @__PURE__ */ jsxs("header", {
907
+ className: cn("flex flex-col gap-1.5", className),
908
+ children: [
909
+ back && (isHrefLink(back) ? /* @__PURE__ */ jsxs("a", {
910
+ href: back.href,
911
+ className: BACK_CLASSES,
912
+ children: ["← ", back.label]
913
+ }) : /* @__PURE__ */ jsxs(Slot, {
914
+ className: BACK_CLASSES,
915
+ children: ["← ", /* @__PURE__ */ jsx(Slottable, { children: back })]
916
+ })),
917
+ /* @__PURE__ */ jsxs("div", {
918
+ className: "flex flex-wrap items-center justify-between gap-x-4 gap-y-2",
919
+ children: [/* @__PURE__ */ jsx("h1", {
920
+ className: "text-xl font-semibold tracking-tight text-fg",
921
+ children: title
922
+ }), actions && /* @__PURE__ */ jsx("div", {
923
+ className: "flex shrink-0 items-center gap-2",
924
+ children: actions
925
+ })]
926
+ }),
927
+ meta && /* @__PURE__ */ jsx("div", {
928
+ className: "flex flex-wrap items-center gap-2 text-xs text-fg-muted",
929
+ children: meta
930
+ })
931
+ ]
932
+ });
933
+ }
934
+ function isHrefLink(back) {
935
+ return !isValidElement(back);
936
+ }
937
+ const READOUT_LINK = "text-fg transition-colors hover:text-signal";
938
+ /**
939
+ * The instrument's display line: the facts about what is on screen, in one fixed slot.
940
+ *
941
+ * Stated in the same place on every screen, in mono, the way a piece of measuring equipment
942
+ * has one readout rather than a label per panel. Rendered as a list because it is one.
943
+ */
944
+ function ReadoutStrip({ items, className }) {
945
+ const shown = items.filter((item) => item.value !== null && item.value !== void 0);
946
+ if (shown.length === 0) return null;
947
+ return /* @__PURE__ */ jsx("ol", {
948
+ className: cn("flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-fg-muted", className),
949
+ children: shown.map((item, index) => /* @__PURE__ */ jsxs("li", {
950
+ className: "flex items-center gap-2",
951
+ children: [
952
+ index > 0 && /* @__PURE__ */ jsx("span", {
953
+ className: "text-fg-subtle",
954
+ "aria-hidden": true,
955
+ children: "·"
956
+ }),
957
+ item.label && /* @__PURE__ */ jsx("span", {
958
+ className: "text-fg-subtle",
959
+ children: item.label
960
+ }),
961
+ item.link ? /* @__PURE__ */ jsxs(Slot, {
962
+ className: READOUT_LINK,
963
+ children: [/* @__PURE__ */ jsx(Slottable, { children: item.link }), item.value]
964
+ }) : item.href ? /* @__PURE__ */ jsx("a", {
965
+ href: item.href,
966
+ className: READOUT_LINK,
967
+ children: item.value
968
+ }) : /* @__PURE__ */ jsx("span", {
969
+ className: "text-fg",
970
+ children: item.value
971
+ })
972
+ ]
973
+ }, index))
974
+ });
975
+ }
976
+ //#endregion
977
+ //#region src/components/ui/SegmentedControl.tsx
978
+ /**
979
+ * A choice among two or three, shown as a strip of segments (native radios in one group, so
980
+ * arrow keys move between them).
981
+ *
982
+ * **`""` means unset.** The highlighted segment is always the *effective* value — `value`,
983
+ * or `defaultValue` while `value` is `""` — and choosing the segment that matches
984
+ * `defaultValue` reports `""`, so the default stays defined in one place. Each segment
985
+ * carries `data-state="checked"` or `"unchecked"`; the group carries `data-disabled`.
986
+ */
987
+ function SegmentedControl({ value, defaultValue, options, onValueChange, disabled = false, "aria-label": ariaLabel, className }) {
988
+ const nameId = useId();
989
+ const described = useFieldDescription();
990
+ const effective = value === "" ? defaultValue : value;
991
+ return /* @__PURE__ */ jsx("div", {
992
+ role: "radiogroup",
993
+ "aria-label": ariaLabel,
994
+ "aria-describedby": described["aria-describedby"],
995
+ "data-disabled": disabled ? "" : void 0,
996
+ className: cn("inline-flex w-fit max-w-full shrink-0 items-center gap-0.5 rounded-control border border-line-strong bg-raised p-0.5", disabled && "opacity-50", className),
997
+ children: options.map((option) => {
998
+ const checked = option.value === effective;
999
+ return /* @__PURE__ */ jsxs("label", {
1000
+ "data-state": checked ? "checked" : "unchecked",
1001
+ className: cn("relative cursor-pointer whitespace-nowrap rounded-[0.3rem] px-2.5 py-1 text-xs font-medium transition-colors", "has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal", checked ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg", disabled && "cursor-not-allowed"),
1002
+ children: [/* @__PURE__ */ jsx("input", {
1003
+ type: "radio",
1004
+ name: nameId,
1005
+ value: option.value,
1006
+ checked,
1007
+ disabled,
1008
+ onChange: () => onValueChange(option.value === defaultValue ? "" : option.value),
1009
+ className: "absolute inset-0 cursor-pointer opacity-0"
1010
+ }), option.label]
1011
+ }, option.value);
1012
+ })
1013
+ });
1014
+ }
1015
+ //#endregion
1016
+ //#region src/components/ui/Select.tsx
1017
+ const UNSET_SENTINEL = "__unset__";
1018
+ /**
1019
+ * A picker whose option list is styled with the app, with room for a quiet note per option.
1020
+ *
1021
+ * Controlled, and **`""` means unset**: the trigger then shows `placeholder`, and the
1022
+ * `unsetLabel` entry (when given) reports `""`. The trigger carries Radix's `data-state`
1023
+ * (`open`/`closed`), `data-placeholder` while unset and `data-disabled`; inside a `Field` it
1024
+ * is described by the field's description and error.
1025
+ */
1026
+ function Select({ value, onValueChange, options, placeholder = "Choose…", unsetLabel, disabled = false, "aria-label": ariaLabel, "aria-describedby": ownDescribedBy, className }) {
1027
+ const [open, setOpen] = useState(false);
1028
+ const described = useFieldDescription(ownDescribedBy);
1029
+ return /* @__PURE__ */ jsxs(RadixSelect.Root, {
1030
+ value,
1031
+ onValueChange: (next) => onValueChange(next === UNSET_SENTINEL ? "" : next),
1032
+ open,
1033
+ onOpenChange: setOpen,
1034
+ disabled,
1035
+ children: [/* @__PURE__ */ jsxs(RadixSelect.Trigger, {
1036
+ "aria-label": ariaLabel,
1037
+ ...described,
1038
+ tabIndex: open ? -1 : void 0,
1039
+ className: cn("flex h-8 w-full items-center justify-between gap-2 rounded-control border border-line-strong bg-raised px-2.5 text-sm text-fg", "transition-colors hover:border-fg-subtle", "disabled:cursor-not-allowed disabled:opacity-50", "data-[placeholder]:text-fg-subtle", focusRingInset, className),
1040
+ children: [/* @__PURE__ */ jsx(RadixSelect.Value, { placeholder }), /* @__PURE__ */ jsx(RadixSelect.Icon, {
1041
+ className: "shrink-0 text-fg-subtle",
1042
+ children: /* @__PURE__ */ jsx(ChevronDown, { className: "size-3.5" })
1043
+ })]
1044
+ }), /* @__PURE__ */ jsx(RadixSelect.Portal, { children: /* @__PURE__ */ jsx(RadixSelect.Content, {
1045
+ position: "popper",
1046
+ sideOffset: 4,
1047
+ className: cn("z-50 max-h-72 min-w-(--radix-select-trigger-width) overflow-hidden", "rounded-panel border border-line bg-overlay shadow-lg shadow-black/25"),
1048
+ children: /* @__PURE__ */ jsxs(RadixSelect.Viewport, {
1049
+ className: "p-1",
1050
+ children: [unsetLabel !== void 0 && /* @__PURE__ */ jsx(SelectItem, {
1051
+ value: UNSET_SENTINEL,
1052
+ label: unsetLabel,
1053
+ muted: true
1054
+ }), options.map((option) => /* @__PURE__ */ jsx(SelectItem, {
1055
+ value: option.value,
1056
+ label: option.label,
1057
+ note: option.note,
1058
+ disabled: option.disabled
1059
+ }, option.value))]
1060
+ })
1061
+ }) })]
1062
+ });
1063
+ }
1064
+ function SelectItem({ value, label, note, disabled, muted = false }) {
1065
+ return /* @__PURE__ */ jsxs(RadixSelect.Item, {
1066
+ value,
1067
+ disabled: disabled ?? false,
1068
+ className: cn("relative flex cursor-default items-center gap-2 rounded-control py-1.5 pr-2 pl-7 text-sm outline-none select-none", "data-highlighted:bg-raised data-highlighted:text-fg", "data-disabled:pointer-events-none data-disabled:opacity-45", muted ? "text-fg-muted" : "text-fg"),
1069
+ children: [
1070
+ /* @__PURE__ */ jsx(RadixSelect.ItemIndicator, {
1071
+ className: "absolute left-2 text-signal",
1072
+ children: /* @__PURE__ */ jsx(Check, { className: "size-3.5" })
1073
+ }),
1074
+ /* @__PURE__ */ jsx(RadixSelect.ItemText, { children: label }),
1075
+ note && /* @__PURE__ */ jsx("span", {
1076
+ className: "ml-auto font-mono text-xs text-fg-subtle",
1077
+ children: note
1078
+ })
1079
+ ]
1080
+ });
1081
+ }
1082
+ //#endregion
1083
+ //#region src/components/ui/Slider.tsx
1084
+ /**
1085
+ * A continuous value, with its number rendered beside the track.
1086
+ *
1087
+ * Controlled. The name (`aria-label`) and a surrounding `Field`'s description go on the
1088
+ * thumb — the element with `role="slider"` that a keyboard and a screen reader operate.
1089
+ * Radix exposes `data-disabled` and `data-orientation` on the parts.
1090
+ */
1091
+ function Slider({ value, onValueChange, onValueCommit, min = 0, max = 1, step = .01, readout, disabled = false, "aria-label": ariaLabel, "aria-describedby": ownDescribedBy, className }) {
1092
+ const described = useFieldDescription(ownDescribedBy);
1093
+ return /* @__PURE__ */ jsxs("div", {
1094
+ className: cn("flex min-w-0 flex-1 items-center gap-3", className),
1095
+ children: [/* @__PURE__ */ jsxs(RadixSlider.Root, {
1096
+ value: [value],
1097
+ onValueChange: ([next]) => next !== void 0 && onValueChange(next),
1098
+ onValueCommit: ([next]) => next !== void 0 && onValueCommit?.(next),
1099
+ min,
1100
+ max,
1101
+ step,
1102
+ disabled,
1103
+ className: cn("relative flex h-5 min-w-0 flex-1 touch-none items-center select-none", "data-disabled:opacity-50"),
1104
+ children: [/* @__PURE__ */ jsx(RadixSlider.Track, {
1105
+ className: "relative h-1 grow overflow-hidden rounded-full bg-line",
1106
+ children: /* @__PURE__ */ jsx(RadixSlider.Range, { className: "absolute h-full bg-signal" })
1107
+ }), /* @__PURE__ */ jsx(RadixSlider.Thumb, {
1108
+ "aria-label": ariaLabel,
1109
+ ...described,
1110
+ className: cn("block size-3.5 rounded-full border-2 border-signal bg-surface transition-shadow", "hover:shadow-[0_0_0_4px] hover:shadow-signal/20", "disabled:cursor-not-allowed", focusRing)
1111
+ })]
1112
+ }), readout && /* @__PURE__ */ jsx("span", {
1113
+ className: "shrink-0 font-mono text-xs text-fg tabular-nums",
1114
+ children: readout
1115
+ })]
1116
+ });
1117
+ }
1118
+ //#endregion
1119
+ //#region src/components/ui/Table.tsx
1120
+ /**
1121
+ * A table of rows, with numeric columns set in mono and right-aligned.
1122
+ *
1123
+ * With `onRowClick` the rows are keyboard-reachable (Tab, then Enter or Space) while keeping
1124
+ * their table semantics. The active row carries `aria-current` and `data-state="active"`;
1125
+ * padding follows the density in force. With no rows it renders the `empty` message instead.
1126
+ */
1127
+ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover }) {
1128
+ const density = useDensity();
1129
+ if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
1130
+ className: cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className),
1131
+ children: empty
1132
+ });
1133
+ return /* @__PURE__ */ jsx("div", {
1134
+ className: cn("w-full overflow-x-auto", className),
1135
+ children: /* @__PURE__ */ jsxs("table", {
1136
+ className: cn("w-full text-left", byDensity(density, "text-sm", "text-[11px]")),
1137
+ children: [
1138
+ caption && /* @__PURE__ */ jsx("caption", {
1139
+ className: "sr-only",
1140
+ children: caption
1141
+ }),
1142
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", {
1143
+ className: "border-b border-line",
1144
+ children: columns.map((column) => /* @__PURE__ */ jsx("th", {
1145
+ scope: "col",
1146
+ style: column.width ? { width: column.width } : void 0,
1147
+ className: cn("font-medium text-fg-muted", byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"), column.numeric && "text-right"),
1148
+ children: column.header
1149
+ }, column.key))
1150
+ }) }),
1151
+ /* @__PURE__ */ jsx("tbody", { children: rows.map((row, index) => /* @__PURE__ */ jsx("tr", {
1152
+ "data-state": isRowActive?.(row, index) ? "active" : void 0,
1153
+ className: cn("border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-raised", isRowActive?.(row, index) && "bg-signal/10"),
1154
+ tabIndex: onRowClick ? 0 : void 0,
1155
+ "aria-current": isRowActive?.(row, index) ? true : void 0,
1156
+ onClick: onRowClick ? (event) => onRowClick(row, index, event) : void 0,
1157
+ onKeyDown: onRowClick ? (event) => {
1158
+ if (event.key === "Enter" || event.key === " ") {
1159
+ event.preventDefault();
1160
+ onRowClick(row, index, event);
1161
+ }
1162
+ } : void 0,
1163
+ onPointerEnter: onRowHover ? () => onRowHover(row, index) : void 0,
1164
+ onPointerLeave: onRowHover ? () => onRowHover(null, null) : void 0,
1165
+ children: columns.map((column) => /* @__PURE__ */ jsx("td", {
1166
+ className: cn("align-middle text-fg last:pr-0", byDensity(density, "py-2 pr-3", "py-0.5 pr-2"), column.numeric && "text-right font-mono tabular-nums"),
1167
+ children: column.cell(row)
1168
+ }, column.key))
1169
+ }, rowKey(row, index))) })
1170
+ ]
1171
+ })
1172
+ });
1173
+ }
1174
+ //#endregion
1175
+ //#region src/components/ui/Toggle.tsx
1176
+ /**
1177
+ * A setting: changes how the thing behaves from now on, and reads as one ("show the mask").
1178
+ * Always labelled; the label and the description sit beside the switch.
1179
+ *
1180
+ * Controlled. Radix exposes `data-state` (`checked`/`unchecked`) and `data-disabled`.
1181
+ */
1182
+ function Switch({ checked, onCheckedChange, label, description, disabled = false, className }) {
1183
+ const descriptionId = useId();
1184
+ return /* @__PURE__ */ jsxs("label", {
1185
+ className: cn("flex min-w-0 items-start gap-2.5", className),
1186
+ children: [/* @__PURE__ */ jsx(RadixSwitch.Root, {
1187
+ checked,
1188
+ onCheckedChange,
1189
+ disabled,
1190
+ "aria-describedby": description ? descriptionId : void 0,
1191
+ className: cn("mt-0.5 h-4 w-7 shrink-0 rounded-full border border-line-strong bg-raised transition-colors", "data-[state=checked]:border-signal data-[state=checked]:bg-signal", "disabled:cursor-not-allowed disabled:opacity-50", focusRing),
1192
+ children: /* @__PURE__ */ jsx(RadixSwitch.Thumb, { className: cn("block size-3 rounded-full bg-fg-subtle transition-transform will-change-transform", "translate-x-0.5 data-[state=checked]:translate-x-3.5 data-[state=checked]:bg-signal-fg") })
1193
+ }), /* @__PURE__ */ jsxs("span", {
1194
+ className: "flex min-w-0 flex-col gap-0.5",
1195
+ children: [/* @__PURE__ */ jsx("span", {
1196
+ className: "text-sm leading-tight text-fg",
1197
+ children: label
1198
+ }), description && /* @__PURE__ */ jsx("span", {
1199
+ id: descriptionId,
1200
+ className: "text-xs leading-snug text-fg-muted",
1201
+ children: description
1202
+ })]
1203
+ })]
1204
+ });
1205
+ }
1206
+ /**
1207
+ * A statement about a state you are about to commit ("keep this channel"). With a `label`
1208
+ * it renders its own row; without one it is a bare box (give it `aria-label`, e.g. in a
1209
+ * table cell).
1210
+ *
1211
+ * Controlled. Radix exposes `data-state` (`checked`/`unchecked`/`indeterminate`) and
1212
+ * `data-disabled`; inside a `Field` it is described by the field's description and error.
1213
+ */
1214
+ function Checkbox({ checked, onCheckedChange, onClick, label, description, disabled = false, "aria-label": ariaLabel, className }) {
1215
+ const descriptionId = useId();
1216
+ const described = useFieldDescription(description ? descriptionId : void 0);
1217
+ const box = /* @__PURE__ */ jsx(RadixCheckbox.Root, {
1218
+ checked,
1219
+ onCheckedChange: (next) => onCheckedChange(next === true),
1220
+ onClick,
1221
+ disabled,
1222
+ "aria-label": ariaLabel,
1223
+ ...described,
1224
+ className: cn("flex size-4 shrink-0 items-center justify-center rounded-[0.25rem] border border-line-strong bg-raised transition-colors", "data-[state=checked]:border-signal data-[state=checked]:bg-signal", "data-[state=indeterminate]:border-signal data-[state=indeterminate]:bg-signal", "disabled:cursor-not-allowed disabled:opacity-50", focusRing, label === void 0 && className),
1225
+ children: /* @__PURE__ */ jsx(RadixCheckbox.Indicator, {
1226
+ className: "text-signal-fg",
1227
+ children: checked === "indeterminate" ? /* @__PURE__ */ jsx(Minus, {
1228
+ className: "size-3",
1229
+ strokeWidth: 3
1230
+ }) : /* @__PURE__ */ jsx(Check, {
1231
+ className: "size-3",
1232
+ strokeWidth: 3
1233
+ })
1234
+ })
1235
+ });
1236
+ if (label === void 0) return box;
1237
+ return /* @__PURE__ */ jsxs("label", {
1238
+ className: cn("flex min-w-0 items-start gap-2.5", className),
1239
+ children: [/* @__PURE__ */ jsx("span", {
1240
+ className: "mt-0.5",
1241
+ children: box
1242
+ }), /* @__PURE__ */ jsxs("span", {
1243
+ className: "flex min-w-0 flex-col gap-0.5",
1244
+ children: [/* @__PURE__ */ jsx("span", {
1245
+ className: "text-sm leading-tight text-fg",
1246
+ children: label
1247
+ }), description && /* @__PURE__ */ jsx("span", {
1248
+ id: descriptionId,
1249
+ className: "text-xs leading-snug text-fg-muted",
1250
+ children: description
1251
+ })]
1252
+ })]
1253
+ });
1254
+ }
1255
+ //#endregion
1256
+ //#region src/components/ui/ToggleChip.tsx
1257
+ /**
1258
+ * A dense on/off chip for a layer drawn on screen, with an optional swatch in the layer's
1259
+ * colour: a toolbar-sized `Switch` (`role="switch"`, `aria-checked`).
1260
+ *
1261
+ * Controlled. State is exposed as `data-state` (`checked`/`unchecked`).
1262
+ */
1263
+ function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = false, title, className }) {
1264
+ return /* @__PURE__ */ jsxs("button", {
1265
+ type: "button",
1266
+ role: "switch",
1267
+ "aria-checked": checked,
1268
+ "data-state": checked ? "checked" : "unchecked",
1269
+ disabled,
1270
+ title,
1271
+ onClick: () => onCheckedChange(!checked),
1272
+ className: cn("inline-flex items-center gap-1.5 rounded-control border px-2 py-1 text-xs whitespace-nowrap transition-colors", checked ? "border-line-strong bg-raised text-fg" : "border-line bg-transparent text-fg-muted hover:text-fg", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-muted", focusRing, className),
1273
+ children: [swatch !== void 0 && /* @__PURE__ */ jsx("span", {
1274
+ "aria-hidden": true,
1275
+ className: cn("size-2 shrink-0 rounded-full border transition-opacity", checked ? "opacity-100" : "opacity-30"),
1276
+ style: {
1277
+ backgroundColor: swatch,
1278
+ borderColor: swatch
1279
+ }
1280
+ }), children]
1281
+ });
1282
+ }
1283
+ //#endregion
1284
+ //#region src/components/Tabs.tsx
1285
+ const NEXT_KEYS = {
1286
+ ArrowRight: (index, length) => (index + 1) % length,
1287
+ ArrowLeft: (index, length) => (index - 1 + length) % length,
1288
+ Home: () => 0,
1289
+ End: (_, length) => length - 1
1290
+ };
1291
+ /**
1292
+ * A tab strip (`role="tablist"`): the caller renders the panel for the `active` tab.
1293
+ *
1294
+ * Keyboard: one tab stop for the whole strip (the active tab); ←/→ move to the previous or
1295
+ * next enabled tab and select it, Home/End to the first and last. Each tab carries
1296
+ * `data-state` (`active`/`inactive`). Pass `idPrefix` to wire the tabs to the panel: the tab
1297
+ * for `id` gets the element id `<idPrefix>-tab-<id>` and the active tab gets
1298
+ * `aria-controls="<idPrefix>-panel-<id>"`, so render the panel as a `role="tabpanel"` element
1299
+ * with the id `<idPrefix>-panel-<active>` and `aria-labelledby="<idPrefix>-tab-<active>"`.
1300
+ */
1301
+ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
1302
+ const enabled = items.filter((item) => !item.disabled);
1303
+ const focusable = enabled.some((item) => item.id === active) ? active : enabled[0]?.id;
1304
+ const onKeyDown = (event) => {
1305
+ const move = NEXT_KEYS[event.key];
1306
+ const target = event.target;
1307
+ const current = enabled.findIndex((item) => item.id === target.dataset["tabId"]);
1308
+ if (move === void 0 || current === -1) return;
1309
+ event.preventDefault();
1310
+ const next = enabled[move(current, enabled.length)];
1311
+ if (next === void 0) return;
1312
+ const tabs = event.currentTarget.querySelectorAll("[role=\"tab\"]");
1313
+ for (const tab of tabs) if (tab.dataset["tabId"] === next.id) tab.focus();
1314
+ if (next.id !== active) onSelect(next.id);
1315
+ };
1316
+ return /* @__PURE__ */ jsx("div", {
1317
+ role: "tablist",
1318
+ "aria-label": label,
1319
+ "aria-orientation": "horizontal",
1320
+ onKeyDown,
1321
+ className: cn("inline-flex max-w-full flex-wrap items-center gap-0.5 rounded-control border border-line bg-raised p-0.5", className),
1322
+ children: items.map((item) => {
1323
+ const selected = item.id === active;
1324
+ return /* @__PURE__ */ jsxs("button", {
1325
+ type: "button",
1326
+ role: "tab",
1327
+ id: idPrefix === void 0 ? void 0 : `${idPrefix}-tab-${item.id}`,
1328
+ "aria-selected": selected,
1329
+ "aria-controls": idPrefix !== void 0 && selected ? `${idPrefix}-panel-${item.id}` : void 0,
1330
+ tabIndex: item.id === focusable ? 0 : -1,
1331
+ "data-tab-id": item.id,
1332
+ "data-state": selected ? "active" : "inactive",
1333
+ disabled: item.disabled,
1334
+ title: item.title,
1335
+ onClick: () => onSelect(item.id),
1336
+ className: cn("inline-flex items-center gap-1.5 rounded-[0.3rem] px-2.5 py-1 text-xs font-medium transition-colors", "disabled:cursor-not-allowed disabled:opacity-40", selected ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg", focusRing),
1337
+ children: [item.label, item.count !== void 0 && item.count > 0 && /* @__PURE__ */ jsx("span", {
1338
+ className: cn("rounded-full px-1.5 font-mono text-[10px] tabular-nums", selected ? "bg-signal/15 text-signal" : "bg-line text-fg-muted"),
1339
+ children: item.count
1340
+ })]
1341
+ }, item.id);
1342
+ })
1343
+ });
1344
+ }
1345
+ //#endregion
1346
+ export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, Empty, ErrorBox, Field, InfoHint, Input, NumberInput, PageHeader, Panel, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, ToggleChip, Tooltip, TooltipProvider, buttonClasses, byDensity, cn, controlClasses, focusRing, focusRingInset, initTheme, inputClasses, readThemeChoice, resolveTheme, setThemeChoice, toneColor, useControlHeight, useDensity };
1347
+
1348
+ //# sourceMappingURL=index.js.map