@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/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +152 -0
- package/dist/index.d.ts +883 -0
- package/dist/index.js +1348 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/components/Tabs.tsx +137 -0
- package/src/components/ThemeToggle.tsx +95 -0
- package/src/components/ui/Badge.tsx +125 -0
- package/src/components/ui/Button.tsx +188 -0
- package/src/components/ui/Density.tsx +66 -0
- package/src/components/ui/Dialog.tsx +187 -0
- package/src/components/ui/Disclosure.tsx +62 -0
- package/src/components/ui/Feedback.tsx +190 -0
- package/src/components/ui/Field.tsx +149 -0
- package/src/components/ui/Input.tsx +100 -0
- package/src/components/ui/Panel.tsx +248 -0
- package/src/components/ui/SegmentedControl.tsx +112 -0
- package/src/components/ui/Select.tsx +168 -0
- package/src/components/ui/Slider.tsx +96 -0
- package/src/components/ui/Table.tsx +162 -0
- package/src/components/ui/Toggle.tsx +163 -0
- package/src/components/ui/ToggleChip.tsx +81 -0
- package/src/components/ui/Tooltip.tsx +99 -0
- package/src/components/ui/cn.ts +41 -0
- package/src/components/ui/index.ts +47 -0
- package/src/index.ts +25 -0
- package/src/styles.css +170 -0
- package/src/theme.ts +113 -0
- package/src/tone.ts +42 -0
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
|