strata-react 0.0.0-stage → 0.1.3

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,1116 @@
1
+ import { a as fetchJson, i as describeHttpError, n as StrataQueryProvider, o as toLoad, r as createQueryClient, s as useLoad, t as HttpError } from "./query-DZVmYJSx.js";
2
+ import { clsx } from "clsx";
3
+ import { extendTailwindMerge } from "tailwind-merge";
4
+ import * as React from "react";
5
+ import { Slot } from "@radix-ui/react-slot";
6
+ import { cva } from "class-variance-authority";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
+ import { ArrowDown, ArrowUp, ArrowUpDown, Check, CircleAlert, LoaderCircle, RotateCcw } from "lucide-react";
9
+ import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
10
+ import { useVirtualizer } from "@tanstack/react-virtual";
11
+ import { Link, NavLink, useSearchParams } from "react-router";
12
+ // src/lib/utils.ts
13
+ var typeSteps = [
14
+ "caption",
15
+ "label",
16
+ "body-sm",
17
+ "body",
18
+ "heading",
19
+ "title",
20
+ "figure",
21
+ "display"
22
+ ];
23
+ var twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ type: [...typeSteps] }] } } });
24
+ /** clsx + tailwind-merge, taught that `type-*` utilities are font sizes. */
25
+ function cn(...inputs) {
26
+ return twMerge(clsx(inputs));
27
+ }
28
+ // src/lib/format.ts
29
+ var NBSP = "\xA0";
30
+ /** Intl formatters for one locale: integers, decimals, percent, times, days, units with a non-breaking space, and counted nouns. */
31
+ function createFormat(locale) {
32
+ const integer = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 });
33
+ const decimal = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
34
+ const percent = new Intl.NumberFormat(locale, {
35
+ style: "percent",
36
+ maximumFractionDigits: 0
37
+ });
38
+ const clock = new Intl.DateTimeFormat(locale, {
39
+ hour: "2-digit",
40
+ minute: "2-digit"
41
+ });
42
+ const day = new Intl.DateTimeFormat(locale, {
43
+ weekday: "long",
44
+ day: "numeric",
45
+ month: "long"
46
+ });
47
+ const shortDay = new Intl.DateTimeFormat(locale, {
48
+ weekday: "short",
49
+ hour: "2-digit",
50
+ minute: "2-digit"
51
+ });
52
+ const withUnit = (value, unit, fmt = integer) => `${fmt.format(value)} ${unit}`;
53
+ return {
54
+ locale,
55
+ integer,
56
+ decimal,
57
+ percent,
58
+ clock,
59
+ day,
60
+ shortDay,
61
+ withUnit,
62
+ celsius: (value) => withUnit(value, "°C"),
63
+ /** "1 piece", "6 pieces": the count and its noun, so copy never reads "1 pieces". */
64
+ count: (value, one, many = `${one}s`) => `${integer.format(value)} ${new Intl.PluralRules(locale).select(value) === "one" ? one : many}`
65
+ };
66
+ }
67
+ /** The en-GB formatters. */
68
+ var format = createFormat("en-GB");
69
+ // src/components/button.tsx
70
+ var buttonVariants = cva("inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-control type-label transition-state press disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", {
71
+ variants: {
72
+ variant: {
73
+ default: "bg-primary text-primary-foreground hover:bg-primary-10",
74
+ outline: "border border-input bg-background text-foreground hover:bg-muted",
75
+ secondary: "bg-muted text-foreground hover:bg-neutral-4",
76
+ ghost: "text-foreground hover:bg-muted",
77
+ destructive: "bg-destructive text-destructive-foreground hover:bg-danger",
78
+ link: "h-auto px-0 text-primary-11 underline underline-offset-4 hover:text-primary-12"
79
+ },
80
+ size: {
81
+ default: "h-(--control-h) px-4",
82
+ sm: "h-(--control-h-sm) px-3",
83
+ icon: "size-(--control-h)"
84
+ }
85
+ },
86
+ defaultVariants: {
87
+ variant: "outline",
88
+ size: "default"
89
+ }
90
+ });
91
+ /** A button with shadcn's variant names. The default variant is `outline`; ask for the filled accent (`variant="default"`) once per view. `type` defaults to `button`. */
92
+ function Button({ className, variant, size, asChild = false, type = "button", ...props }) {
93
+ return /* @__PURE__ */ jsx(asChild ? Slot : "button", {
94
+ type: asChild ? void 0 : type,
95
+ className: cn(buttonVariants({
96
+ variant,
97
+ size
98
+ }), className),
99
+ ...props
100
+ });
101
+ }
102
+ /** An icon-only button. `label` is required and becomes the accessible name and tooltip. */
103
+ function IconButton({ icon: Icon, label, variant = "ghost", ...props }) {
104
+ return /* @__PURE__ */ jsx(Button, {
105
+ size: "icon",
106
+ variant,
107
+ "aria-label": label,
108
+ title: label,
109
+ ...props,
110
+ children: /* @__PURE__ */ jsx(Icon, { "aria-hidden": true })
111
+ });
112
+ }
113
+ // src/components/async-button.tsx
114
+ /** One button through idle, pending, done and error, with the label changing in place and its width fixed by the longest label. The outcome is announced politely. */
115
+ function AsyncButton({ phase, labels, disabled, ...props }) {
116
+ const errorLabel = labels.error ?? "Try again";
117
+ const current = {
118
+ idle: labels.idle,
119
+ pending: labels.pending,
120
+ done: labels.done,
121
+ error: errorLabel
122
+ }[phase];
123
+ const Icon = {
124
+ idle: null,
125
+ pending: LoaderCircle,
126
+ done: Check,
127
+ error: CircleAlert
128
+ }[phase];
129
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Button, {
130
+ ...props,
131
+ disabled: disabled || phase === "pending",
132
+ "aria-disabled": phase === "pending" || void 0,
133
+ children: [Icon && /* @__PURE__ */ jsx(Icon, {
134
+ "aria-hidden": true,
135
+ className: phase === "pending" ? "motion-safe:animate-spin" : void 0
136
+ }), /* @__PURE__ */ jsx("span", {
137
+ className: "grid",
138
+ children: [
139
+ labels.idle,
140
+ labels.pending,
141
+ labels.done,
142
+ errorLabel
143
+ ].map((l) => /* @__PURE__ */ jsx("span", {
144
+ "aria-hidden": l !== current,
145
+ className: l === current ? "[grid-area:1/1]" : "invisible [grid-area:1/1]",
146
+ children: l
147
+ }, l))
148
+ })]
149
+ }), /* @__PURE__ */ jsx("span", {
150
+ role: "status",
151
+ className: "sr-only",
152
+ children: phase === "done" ? labels.done : phase === "error" ? errorLabel : ""
153
+ })] });
154
+ }
155
+ /** Runs an async action and reports its phase. `done` returns to `idle` after `settleMs`. */
156
+ function useAsyncAction(action, settleMs = 2e3) {
157
+ const [phase, setPhase] = React.useState("idle");
158
+ const timer = React.useRef(void 0);
159
+ React.useEffect(() => () => window.clearTimeout(timer.current), []);
160
+ return [phase, React.useCallback(async (...args) => {
161
+ window.clearTimeout(timer.current);
162
+ setPhase("pending");
163
+ try {
164
+ await action(...args);
165
+ setPhase("done");
166
+ timer.current = window.setTimeout(() => setPhase("idle"), settleMs);
167
+ } catch {
168
+ setPhase("error");
169
+ }
170
+ }, [action, settleMs])];
171
+ }
172
+ // src/components/field.tsx
173
+ var FieldContext = React.createContext(null);
174
+ function useField(id) {
175
+ const field = React.useContext(FieldContext);
176
+ return {
177
+ id: field?.id ?? id,
178
+ "aria-describedby": field?.describedBy,
179
+ "aria-invalid": field?.invalid || void 0
180
+ };
181
+ }
182
+ /** A labelled form field: wires the label, hint, error, `aria-describedby` and `aria-invalid` into the one control it wraps. */
183
+ function Field({ label, id: idProp, hint, error, children, className }) {
184
+ const generated = React.useId();
185
+ const id = idProp ?? generated;
186
+ const hintId = hint ? `${id}-hint` : void 0;
187
+ const errorId = error ? `${id}-error` : void 0;
188
+ const describedBy = [errorId, hintId].filter(Boolean).join(" ") || void 0;
189
+ return /* @__PURE__ */ jsx(FieldContext.Provider, {
190
+ value: {
191
+ id,
192
+ describedBy,
193
+ invalid: Boolean(error)
194
+ },
195
+ children: /* @__PURE__ */ jsxs("div", {
196
+ className: cn("grid min-w-0 content-start gap-1.5", className),
197
+ children: [
198
+ /* @__PURE__ */ jsx("label", {
199
+ htmlFor: id,
200
+ className: "type-label text-foreground",
201
+ children: label
202
+ }),
203
+ children,
204
+ error && /* @__PURE__ */ jsxs("p", {
205
+ id: errorId,
206
+ className: "flex items-start gap-1.5 type-body-sm text-danger",
207
+ children: [/* @__PURE__ */ jsx(CircleAlert, {
208
+ "aria-hidden": true,
209
+ className: "mt-0.5 size-4 shrink-0"
210
+ }), error]
211
+ }),
212
+ hint && /* @__PURE__ */ jsx("p", {
213
+ id: hintId,
214
+ className: "type-body-sm text-muted-foreground",
215
+ children: hint
216
+ })
217
+ ]
218
+ })
219
+ });
220
+ }
221
+ var control = "w-full min-w-0 rounded-control border border-input bg-background px-3 type-body text-foreground transition-state placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-70 aria-invalid:border-danger";
222
+ /** A text input that picks up its id and descriptions from the surrounding `Field`. */
223
+ function Input({ className, id, ...props }) {
224
+ return /* @__PURE__ */ jsx("input", {
225
+ ...useField(id),
226
+ className: cn(control, "h-(--control-h)", className),
227
+ ...props
228
+ });
229
+ }
230
+ /** A multi-line input that picks up its id and descriptions from the surrounding `Field`. */
231
+ function Textarea({ className, id, ...props }) {
232
+ return /* @__PURE__ */ jsx("textarea", {
233
+ ...useField(id),
234
+ className: cn(control, "min-h-24 py-2", className),
235
+ ...props
236
+ });
237
+ }
238
+ /** The platform select, styled. The default for short lists, especially on touch screens. */
239
+ function NativeSelect({ className, id, ...props }) {
240
+ return /* @__PURE__ */ jsx("select", {
241
+ ...useField(id),
242
+ className: cn(control, "h-(--control-h) pr-8", className),
243
+ ...props
244
+ });
245
+ }
246
+ /** Radio choices in a fieldset with a legend; each whole row is the hit target. For 2 to 5 options that each need a sentence. */
247
+ function ChoiceGroup({ legend, name, choices, value, onChange, error }) {
248
+ const errorId = React.useId();
249
+ return /* @__PURE__ */ jsxs("fieldset", {
250
+ className: "grid gap-2",
251
+ "aria-describedby": error ? errorId : void 0,
252
+ children: [
253
+ /* @__PURE__ */ jsx("legend", {
254
+ className: "mb-1.5 type-label text-foreground",
255
+ children: legend
256
+ }),
257
+ choices.map((c) => /* @__PURE__ */ jsxs("label", {
258
+ className: "flex min-h-(--control-h-sm) cursor-pointer items-start gap-3 py-1",
259
+ children: [/* @__PURE__ */ jsx("input", {
260
+ type: "radio",
261
+ name,
262
+ value: c.value,
263
+ checked: value === c.value,
264
+ onChange: () => onChange(c.value),
265
+ className: "mt-1 size-4 shrink-0 accent-primary"
266
+ }), /* @__PURE__ */ jsxs("span", {
267
+ className: "grid",
268
+ children: [/* @__PURE__ */ jsx("span", {
269
+ className: "type-body",
270
+ children: c.label
271
+ }), c.detail && /* @__PURE__ */ jsx("span", {
272
+ className: "type-body-sm text-muted-foreground",
273
+ children: c.detail
274
+ })]
275
+ })]
276
+ }, c.value)),
277
+ error && /* @__PURE__ */ jsxs("p", {
278
+ id: errorId,
279
+ className: "flex items-start gap-1.5 type-body-sm text-danger",
280
+ children: [/* @__PURE__ */ jsx(CircleAlert, {
281
+ "aria-hidden": true,
282
+ className: "mt-0.5 size-4 shrink-0"
283
+ }), error]
284
+ })
285
+ ]
286
+ });
287
+ }
288
+ // src/components/status.tsx
289
+ var tones = {
290
+ neutral: "bg-muted text-muted-foreground",
291
+ info: "bg-info-subtle text-info",
292
+ success: "bg-success-subtle text-success",
293
+ warning: "bg-warning-subtle text-warning",
294
+ danger: "bg-danger-subtle text-danger"
295
+ };
296
+ /** A status as a word on a pale tint of its tone, with an optional icon. Never colour alone. */
297
+ function Status({ tone, icon: Icon, children, className }) {
298
+ return /* @__PURE__ */ jsxs("span", {
299
+ className: cn("inline-flex items-center gap-1.5 whitespace-nowrap rounded-pill py-0.5 pr-2.5 pl-2 type-caption font-medium", tones[tone], className),
300
+ children: [Icon && /* @__PURE__ */ jsx(Icon, {
301
+ "aria-hidden": true,
302
+ className: "size-3.5 shrink-0"
303
+ }), children]
304
+ });
305
+ }
306
+ // src/components/metric.tsx
307
+ /** Headline figures as one description list split by hairlines. Holds `Metric`s. */
308
+ function MetricStrip({ label, children, className }) {
309
+ return /* @__PURE__ */ jsx("dl", {
310
+ "aria-label": label,
311
+ className: cn("grid grid-cols-1 border-y border-border sm:auto-cols-fr sm:grid-flow-col sm:divide-x sm:divide-border", className),
312
+ children
313
+ });
314
+ }
315
+ /** One figure: label over value, an optional muted unit and a note that states any change in words with its window. */
316
+ function Metric({ label, value, unit, note }) {
317
+ return /* @__PURE__ */ jsxs("div", {
318
+ className: "grid content-start gap-0.5 border-border py-3 not-first:border-t sm:px-5 sm:not-first:border-t-0 sm:first:pl-0",
319
+ children: [
320
+ /* @__PURE__ */ jsx("dt", {
321
+ className: "type-label text-muted-foreground",
322
+ children: label
323
+ }),
324
+ /* @__PURE__ */ jsxs("dd", {
325
+ className: "type-figure text-foreground",
326
+ children: [value, unit && /* @__PURE__ */ jsx("span", {
327
+ className: "type-body text-muted-foreground",
328
+ children: "\xA0" + unit
329
+ })]
330
+ }),
331
+ note && /* @__PURE__ */ jsx("dd", {
332
+ className: "type-body-sm text-muted-foreground",
333
+ children: note
334
+ })
335
+ ]
336
+ });
337
+ }
338
+ // src/components/data-view.tsx
339
+ /** Every view of remote data: renders `skeleton` while loading, an alert with Try again on error, `empty` when `isEmpty`, else `children(data)`. The skeleton and empty state are required. */
340
+ function DataView({ state, isEmpty, skeleton, empty, children, className }) {
341
+ return /* @__PURE__ */ jsxs("div", {
342
+ className: cn("min-w-0", className),
343
+ "aria-busy": state.status === "loading" || void 0,
344
+ children: [
345
+ state.status === "loading" && skeleton,
346
+ state.status === "error" && /* @__PURE__ */ jsxs("div", {
347
+ role: "alert",
348
+ className: "flex flex-wrap items-start gap-3 rounded-surface bg-danger-subtle p-4 text-danger",
349
+ children: [
350
+ /* @__PURE__ */ jsx(CircleAlert, {
351
+ "aria-hidden": true,
352
+ className: "mt-0.5 size-5 shrink-0"
353
+ }),
354
+ /* @__PURE__ */ jsx("p", {
355
+ className: "min-w-0 flex-1 type-body",
356
+ children: state.message
357
+ }),
358
+ /* @__PURE__ */ jsxs(Button, {
359
+ size: "sm",
360
+ onClick: state.retry,
361
+ children: [/* @__PURE__ */ jsx(RotateCcw, { "aria-hidden": true }), "Try again"]
362
+ })
363
+ ]
364
+ }),
365
+ state.status === "ready" && (isEmpty(state.data) ? empty : children(state.data))
366
+ ]
367
+ });
368
+ }
369
+ /** An empty state that teaches the next action: a title, a sentence and an action. No illustration slot, on purpose. */
370
+ function EmptyState({ title, children, action }) {
371
+ return /* @__PURE__ */ jsxs("div", {
372
+ className: "grid justify-items-start gap-2 border-y border-border py-6",
373
+ children: [
374
+ /* @__PURE__ */ jsx("p", {
375
+ className: "type-heading",
376
+ children: title
377
+ }),
378
+ children && /* @__PURE__ */ jsx("p", {
379
+ className: "max-w-prose type-body text-muted-foreground",
380
+ children
381
+ }),
382
+ action && /* @__PURE__ */ jsx("div", {
383
+ className: "mt-2",
384
+ children: action
385
+ })
386
+ ]
387
+ });
388
+ }
389
+ /** A placeholder block for a loading layout. Size it with layout classes so it mirrors the final content. */
390
+ function Skeleton({ className }) {
391
+ return /* @__PURE__ */ jsx("div", {
392
+ "aria-hidden": true,
393
+ className: cn("rounded-control bg-muted motion-safe:animate-pulse", className)
394
+ });
395
+ }
396
+ // src/components/data-table.tsx
397
+ function useLocalSort(initial) {
398
+ const [sort, setSort] = React.useState(initial);
399
+ return [sort, setSort];
400
+ }
401
+ /** Reads a sort from its URL form (`pieces.desc`). */
402
+ function parseSort(value) {
403
+ if (!value) return void 0;
404
+ const [key, dir] = value.split(".");
405
+ if (!key) return void 0;
406
+ return {
407
+ key,
408
+ dir: dir === "desc" ? "descending" : "ascending"
409
+ };
410
+ }
411
+ /** Writes a sort in its URL form (`pieces.desc`). */
412
+ function serialiseSort(sort) {
413
+ return `${sort.key}.${sort.dir === "descending" ? "desc" : "asc"}`;
414
+ }
415
+ function useUrlSort(param, initial) {
416
+ const [params, setParams] = useSearchParams();
417
+ return [parseSort(params.get(param)) ?? initial, React.useCallback((next) => setParams((p) => {
418
+ const out = new URLSearchParams(p);
419
+ out.set(param, serialiseSort(next));
420
+ return out;
421
+ }, { replace: true }), [param, setParams])];
422
+ }
423
+ /** A table on TanStack Table: muted headers, row rules, right-aligned tabular numbers, sortable headers with `aria-sort`, virtualised from `virtualizeFrom` rows, sort optionally kept in the URL (`sortParam`). */
424
+ function DataTable(props) {
425
+ return props.sortParam ? /* @__PURE__ */ jsx(UrlSortedTable, {
426
+ ...props,
427
+ sortParam: props.sortParam
428
+ }) : /* @__PURE__ */ jsx(LocalSortedTable, { ...props });
429
+ }
430
+ function LocalSortedTable(props) {
431
+ return /* @__PURE__ */ jsx(Table, {
432
+ ...props,
433
+ sortState: useLocalSort(props.initialSort)
434
+ });
435
+ }
436
+ function UrlSortedTable(props) {
437
+ return /* @__PURE__ */ jsx(Table, {
438
+ ...props,
439
+ sortState: useUrlSort(props.sortParam, props.initialSort)
440
+ });
441
+ }
442
+ function Table({ caption, columns, rows, rowKey, sortState: [sort, setSort], virtualizeFrom = 100, maxHeight = 480, className }) {
443
+ const defs = React.useMemo(() => columns.map((c) => ({
444
+ id: c.key,
445
+ accessorFn: (row) => row,
446
+ header: c.header,
447
+ cell: (info) => c.cell(info.row.original),
448
+ enableSorting: Boolean(c.sort),
449
+ sortingFn: c.sort ? (a, b) => c.sort(a.original, b.original) : void 0,
450
+ meta: {
451
+ numeric: c.numeric,
452
+ truncate: c.truncate,
453
+ className: c.className
454
+ }
455
+ })), [columns]);
456
+ const sorting = sort && columns.some((c) => c.key === sort.key && c.sort) ? [{
457
+ id: sort.key,
458
+ desc: sort.dir === "descending"
459
+ }] : [];
460
+ const table = useReactTable({
461
+ data: rows,
462
+ columns: defs,
463
+ state: { sorting },
464
+ getRowId: (row) => rowKey(row),
465
+ getCoreRowModel: getCoreRowModel(),
466
+ getSortedRowModel: getSortedRowModel()
467
+ });
468
+ const toggle = (key) => setSort({
469
+ key,
470
+ dir: sort?.key === key && sort.dir === "ascending" ? "descending" : "ascending"
471
+ });
472
+ const modelRows = table.getRowModel().rows;
473
+ const virtual = modelRows.length >= virtualizeFrom;
474
+ const scroller = React.useRef(null);
475
+ const virtualizer = useVirtualizer({
476
+ count: virtual ? modelRows.length : 0,
477
+ getScrollElement: () => scroller.current,
478
+ estimateSize: () => 41,
479
+ overscan: 8,
480
+ initialRect: {
481
+ width: 0,
482
+ height: maxHeight
483
+ }
484
+ });
485
+ const items = virtual ? virtualizer.getVirtualItems() : [];
486
+ const padTop = items.length ? items[0].start : 0;
487
+ const padBottom = items.length ? virtualizer.getTotalSize() - items[items.length - 1].end : 0;
488
+ const visible = virtual ? items.map((i) => modelRows[i.index]) : modelRows;
489
+ return /* @__PURE__ */ jsx("div", {
490
+ ref: scroller,
491
+ role: "region",
492
+ "aria-label": caption,
493
+ tabIndex: 0,
494
+ className: cn("min-w-0 overflow-x-auto", virtual && "overflow-y-auto", className),
495
+ style: virtual ? { maxHeight } : void 0,
496
+ children: /* @__PURE__ */ jsxs("table", {
497
+ className: "w-full border-collapse type-body-sm",
498
+ "aria-rowcount": virtual ? modelRows.length + 1 : void 0,
499
+ children: [
500
+ /* @__PURE__ */ jsx("caption", {
501
+ className: "sr-only",
502
+ children: caption
503
+ }),
504
+ /* @__PURE__ */ jsx("thead", {
505
+ className: cn(virtual && "sticky top-0 z-10 bg-background"),
506
+ children: table.getHeaderGroups().map((group) => /* @__PURE__ */ jsx("tr", { children: group.headers.map((header) => {
507
+ const meta = header.column.columnDef.meta;
508
+ const active = sort?.key === header.id && header.column.getCanSort() ? sort.dir : void 0;
509
+ const Icon = active === "ascending" ? ArrowUp : active === "descending" ? ArrowDown : ArrowUpDown;
510
+ const label = flexRender(header.column.columnDef.header, header.getContext());
511
+ return /* @__PURE__ */ jsx("th", {
512
+ scope: "col",
513
+ "aria-sort": active,
514
+ className: cn("py-2 pr-4 text-left align-bottom type-caption text-muted-foreground last:pr-0", meta?.numeric && "text-right"),
515
+ children: header.column.getCanSort() ? /* @__PURE__ */ jsxs("button", {
516
+ type: "button",
517
+ onClick: () => toggle(header.id),
518
+ className: cn("-mx-1 inline-flex cursor-pointer items-center gap-1 rounded-control px-1 transition-state hover:text-foreground", meta?.numeric && "flex-row-reverse", active && "text-foreground"),
519
+ children: [label, /* @__PURE__ */ jsx(Icon, {
520
+ "aria-hidden": true,
521
+ className: cn("size-3.5", !active && "opacity-50")
522
+ })]
523
+ }) : label
524
+ }, header.id);
525
+ }) }, group.id))
526
+ }),
527
+ /* @__PURE__ */ jsxs("tbody", { children: [
528
+ padTop > 0 && /* @__PURE__ */ jsx("tr", {
529
+ "aria-hidden": true,
530
+ style: { height: padTop }
531
+ }),
532
+ visible.map((row, i) => /* @__PURE__ */ jsx("tr", {
533
+ className: "border-t border-border",
534
+ "aria-rowindex": virtual ? (items[i]?.index ?? 0) + 2 : void 0,
535
+ children: row.getVisibleCells().map((cell) => {
536
+ const meta = cell.column.columnDef.meta;
537
+ return /* @__PURE__ */ jsx("td", {
538
+ className: cn("py-2.5 pr-4 align-middle last:pr-0", meta?.numeric && "text-right tabular-nums", meta?.truncate && "max-w-48 truncate", meta?.className),
539
+ children: flexRender(cell.column.columnDef.cell, cell.getContext())
540
+ }, cell.id);
541
+ })
542
+ }, row.id)),
543
+ padBottom > 0 && /* @__PURE__ */ jsx("tr", {
544
+ "aria-hidden": true,
545
+ style: { height: padBottom }
546
+ })
547
+ ] })
548
+ ]
549
+ })
550
+ });
551
+ }
552
+ /** The loading placeholder for a `DataTable`, shaped like its rows. */
553
+ function TableSkeleton({ rows = 4, columns = 5 }) {
554
+ return /* @__PURE__ */ jsxs("div", {
555
+ "aria-hidden": true,
556
+ className: "grid gap-0",
557
+ children: [/* @__PURE__ */ jsx("div", {
558
+ className: "flex gap-4 py-2",
559
+ children: Array.from({ length: columns }, (_, i) => /* @__PURE__ */ jsx("div", { className: "h-3 flex-1 rounded-control bg-muted" }, i))
560
+ }), Array.from({ length: rows }, (_, r) => /* @__PURE__ */ jsx("div", {
561
+ className: "flex gap-4 border-t border-border py-3.5",
562
+ children: Array.from({ length: columns }, (_, i) => /* @__PURE__ */ jsx("div", { className: "h-4 flex-1 rounded-control bg-muted motion-safe:animate-pulse" }, i))
563
+ }, r))]
564
+ });
565
+ }
566
+ // src/components/virtual-list.tsx
567
+ /** A list that renders only its visible rows (TanStack Virtual) from `virtualizeFrom` items, keeping list semantics for screen readers. */
568
+ function VirtualList({ label, items, itemKey, children, estimateSize = 48, height = 480, virtualizeFrom = 100, className }) {
569
+ const scroller = React.useRef(null);
570
+ const virtual = items.length >= virtualizeFrom;
571
+ const virtualizer = useVirtualizer({
572
+ count: virtual ? items.length : 0,
573
+ getScrollElement: () => scroller.current,
574
+ estimateSize: () => estimateSize,
575
+ overscan: 6,
576
+ initialRect: {
577
+ width: 0,
578
+ height
579
+ }
580
+ });
581
+ if (!virtual) return /* @__PURE__ */ jsx("ul", {
582
+ "aria-label": label,
583
+ className: cn("grid min-w-0", className),
584
+ children: items.map((item, i) => /* @__PURE__ */ jsx("li", {
585
+ className: "border-t border-border first:border-t-0",
586
+ children: children(item, i)
587
+ }, itemKey(item)))
588
+ });
589
+ return /* @__PURE__ */ jsx("div", {
590
+ ref: scroller,
591
+ role: "region",
592
+ "aria-label": label,
593
+ tabIndex: 0,
594
+ className: cn("min-w-0 overflow-y-auto", className),
595
+ style: { height },
596
+ children: /* @__PURE__ */ jsx("ul", {
597
+ "aria-label": label,
598
+ className: "relative w-full",
599
+ style: { height: virtualizer.getTotalSize() },
600
+ children: virtualizer.getVirtualItems().map((v) => {
601
+ const item = items[v.index];
602
+ return /* @__PURE__ */ jsx("li", {
603
+ ref: virtualizer.measureElement,
604
+ "data-index": v.index,
605
+ "aria-setsize": items.length,
606
+ "aria-posinset": v.index + 1,
607
+ className: cn("absolute inset-x-0 top-0", v.index > 0 && "border-t border-border"),
608
+ style: { translate: `0 ${v.start}px` },
609
+ children: children(item, v.index)
610
+ }, itemKey(item));
611
+ })
612
+ })
613
+ });
614
+ }
615
+ // src/components/line-chart.tsx
616
+ function useWidth() {
617
+ const ref = React.useRef(null);
618
+ const [width, setWidth] = React.useState(0);
619
+ React.useLayoutEffect(() => {
620
+ const el = ref.current;
621
+ if (!el) return;
622
+ setWidth(el.getBoundingClientRect().width);
623
+ const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
624
+ ro.observe(el);
625
+ return () => ro.disconnect();
626
+ }, []);
627
+ return [ref, width];
628
+ }
629
+ /** Four gridline steps of a size people write on a scale (1, 2, 2.5, 4 or 5 × 10ⁿ), ending above the max. */
630
+ function niceMax(max) {
631
+ if (!(max > 0)) return 4;
632
+ const raw = max / 4;
633
+ const mag = 10 ** Math.floor(Math.log10(raw));
634
+ return ([
635
+ 1,
636
+ 2,
637
+ 2.5,
638
+ 4,
639
+ 5,
640
+ 10
641
+ ].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag) * 4;
642
+ }
643
+ /** One measured series, drawn in real pixels, with the peak and current readings labelled on the chart and every reading in a screen-reader table. Optional plan, threshold band and `colourScale`. */
644
+ function LineChart({ label, series, seriesLabel, planned, plannedLabel, band, formatX, formatY, xTicks, colour = "var(--chart-1)", colourScale, colourScaleLabel, height = 280, className }) {
645
+ const [ref, width] = useWidth();
646
+ const gradientId = `heat-${React.useId().replace(/[^\w-]/g, "")}`;
647
+ const all = [...series, ...planned ?? []];
648
+ const xMin = Math.min(...all.map((p) => p.x));
649
+ const xMax = Math.max(...all.map((p) => p.x));
650
+ const yMax = niceMax(Math.max(...all.map((p) => p.y)));
651
+ const yTicks = [
652
+ 0,
653
+ .25,
654
+ .5,
655
+ .75,
656
+ 1
657
+ ].map((f) => f * yMax);
658
+ const pad = {
659
+ top: 16,
660
+ right: 16,
661
+ bottom: 28,
662
+ left: 56
663
+ };
664
+ const w = Math.max(0, width - pad.left - pad.right);
665
+ const h = height - pad.top - pad.bottom;
666
+ const sx = (x) => pad.left + (x - xMin) / (xMax - xMin || 1) * w;
667
+ const sy = (y) => pad.top + h - y / yMax * h;
668
+ const path = (pts) => pts.map((p, i) => `${i ? "L" : "M"}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join("");
669
+ const last = series[series.length - 1];
670
+ const peak = series.reduce((a, b) => b.y > a.y ? b : a, series[0]);
671
+ const stops = colourScale ? [...colourScale].sort((a, b) => a.at - b.at) : [];
672
+ /** The colour a value lands on: the highest stop at or below it. Used for the labelled points. */
673
+ const colourAt = (y) => stops.length ? [...stops].reverse().find((s) => s.at <= y)?.colour ?? stops[0].colour : colour;
674
+ const stroke = stops.length ? `url(#${gradientId})` : colour;
675
+ return /* @__PURE__ */ jsxs("figure", {
676
+ className: cn("grid min-w-0 gap-2", className),
677
+ children: [
678
+ /* @__PURE__ */ jsx("div", {
679
+ ref,
680
+ className: "min-w-0",
681
+ children: width > 0 && /* @__PURE__ */ jsxs("svg", {
682
+ width,
683
+ height,
684
+ role: "img",
685
+ "aria-label": label,
686
+ className: "block overflow-visible",
687
+ children: [
688
+ stops.length > 0 && /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("linearGradient", {
689
+ id: gradientId,
690
+ gradientUnits: "userSpaceOnUse",
691
+ x1: 0,
692
+ x2: 0,
693
+ y1: sy(0),
694
+ y2: sy(yMax),
695
+ children: stops.map((s) => /* @__PURE__ */ jsx("stop", {
696
+ offset: Math.min(1, Math.max(0, s.at / yMax)),
697
+ stopColor: s.colour
698
+ }, s.at))
699
+ }) }),
700
+ band && /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("rect", {
701
+ x: pad.left,
702
+ y: sy(band.below),
703
+ width: w,
704
+ height: sy(0) - sy(band.below),
705
+ fill: "var(--success-subtle)"
706
+ }), /* @__PURE__ */ jsx("text", {
707
+ x: pad.left + 8,
708
+ y: sy(band.below) + 16,
709
+ className: "fill-success type-caption",
710
+ children: band.label
711
+ })] }),
712
+ yTicks.map((t) => /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("line", {
713
+ x1: pad.left,
714
+ x2: pad.left + w,
715
+ y1: sy(t),
716
+ y2: sy(t),
717
+ stroke: "var(--border)",
718
+ strokeWidth: 1
719
+ }), /* @__PURE__ */ jsx("text", {
720
+ x: pad.left - 8,
721
+ y: sy(t),
722
+ dy: "0.32em",
723
+ textAnchor: "end",
724
+ className: "fill-muted-foreground type-caption tabular-nums",
725
+ children: formatY(t)
726
+ })] }, t)),
727
+ xTicks.map((t) => /* @__PURE__ */ jsx("text", {
728
+ x: sx(t),
729
+ y: height - 8,
730
+ textAnchor: "middle",
731
+ className: "fill-muted-foreground type-caption tabular-nums",
732
+ children: formatX(t)
733
+ }, t)),
734
+ planned && /* @__PURE__ */ jsx("path", {
735
+ d: path(planned),
736
+ fill: "none",
737
+ stroke: "var(--muted-foreground)",
738
+ strokeWidth: 1.5,
739
+ strokeDasharray: "4 4"
740
+ }),
741
+ /* @__PURE__ */ jsx("path", {
742
+ d: path(series),
743
+ fill: "none",
744
+ stroke,
745
+ strokeWidth: stops.length ? 2.5 : 2,
746
+ strokeLinejoin: "round",
747
+ strokeLinecap: "round"
748
+ }),
749
+ /* @__PURE__ */ jsx("circle", {
750
+ cx: sx(peak.x),
751
+ cy: sy(peak.y),
752
+ r: 3,
753
+ fill: colourAt(peak.y)
754
+ }),
755
+ /* @__PURE__ */ jsxs("text", {
756
+ x: sx(peak.x) + 8,
757
+ y: sy(peak.y) - 8,
758
+ className: "fill-foreground type-caption tabular-nums",
759
+ children: [formatY(peak.y), " peak"]
760
+ }),
761
+ /* @__PURE__ */ jsx("line", {
762
+ x1: sx(last.x),
763
+ x2: sx(last.x),
764
+ y1: pad.top,
765
+ y2: pad.top + h,
766
+ stroke: "var(--foreground)",
767
+ strokeWidth: 1
768
+ }),
769
+ /* @__PURE__ */ jsx("circle", {
770
+ cx: sx(last.x),
771
+ cy: sy(last.y),
772
+ r: 3.5,
773
+ fill: colourAt(last.y)
774
+ }),
775
+ /* @__PURE__ */ jsxs("text", {
776
+ x: sx(last.x) - 8,
777
+ y: sy(last.y) - 10,
778
+ textAnchor: "end",
779
+ className: "fill-foreground type-label tabular-nums",
780
+ children: [formatY(last.y), " now"]
781
+ })
782
+ ]
783
+ })
784
+ }),
785
+ /* @__PURE__ */ jsxs("figcaption", {
786
+ className: "flex flex-wrap gap-x-5 gap-y-1 type-caption text-muted-foreground",
787
+ children: [/* @__PURE__ */ jsxs("span", {
788
+ className: "inline-flex items-center gap-2",
789
+ children: [
790
+ stops.length ? /* @__PURE__ */ jsx("svg", {
791
+ width: stops.length * 8,
792
+ height: "3",
793
+ "aria-hidden": true,
794
+ children: stops.map((s, i) => /* @__PURE__ */ jsx("line", {
795
+ x1: i * 8,
796
+ x2: i * 8 + 8,
797
+ y1: "1.5",
798
+ y2: "1.5",
799
+ stroke: s.colour,
800
+ strokeWidth: 3
801
+ }, s.at))
802
+ }) : /* @__PURE__ */ jsx("svg", {
803
+ width: "16",
804
+ height: "2",
805
+ "aria-hidden": true,
806
+ children: /* @__PURE__ */ jsx("line", {
807
+ x1: "0",
808
+ x2: "16",
809
+ y1: "1",
810
+ y2: "1",
811
+ stroke: colour,
812
+ strokeWidth: 2
813
+ })
814
+ }),
815
+ seriesLabel,
816
+ stops.length > 0 && colourScaleLabel && /* @__PURE__ */ jsxs("span", { children: [
817
+ "(",
818
+ colourScaleLabel,
819
+ ")"
820
+ ] })
821
+ ]
822
+ }), planned && plannedLabel && /* @__PURE__ */ jsxs("span", {
823
+ className: "inline-flex items-center gap-2",
824
+ children: [/* @__PURE__ */ jsx("svg", {
825
+ width: "16",
826
+ height: "2",
827
+ "aria-hidden": true,
828
+ children: /* @__PURE__ */ jsx("line", {
829
+ x1: "0",
830
+ x2: "16",
831
+ y1: "1",
832
+ y2: "1",
833
+ stroke: "var(--muted-foreground)",
834
+ strokeWidth: 1.5,
835
+ strokeDasharray: "4 4"
836
+ })
837
+ }), plannedLabel]
838
+ })]
839
+ }),
840
+ /* @__PURE__ */ jsx("div", {
841
+ className: "sr-only",
842
+ children: /* @__PURE__ */ jsxs("table", { children: [
843
+ /* @__PURE__ */ jsx("caption", { children: label }),
844
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("th", {
845
+ scope: "col",
846
+ children: "Time"
847
+ }), /* @__PURE__ */ jsx("th", {
848
+ scope: "col",
849
+ children: seriesLabel
850
+ })] }) }),
851
+ /* @__PURE__ */ jsx("tbody", { children: series.map((p) => /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("td", { children: formatX(p.x) }), /* @__PURE__ */ jsx("td", { children: formatY(p.y) })] }, p.x)) })
852
+ ] })
853
+ })
854
+ ]
855
+ });
856
+ }
857
+ // src/components/page-header.tsx
858
+ /** The top of an Operate screen: a noun title, its scope, the main action, and the finding (the one fact in today's data that most needs attention). */
859
+ function PageHeader({ title, scope, actions, finding, as: Heading = "h1" }) {
860
+ return /* @__PURE__ */ jsxs("header", {
861
+ className: "grid gap-4",
862
+ children: [/* @__PURE__ */ jsxs("div", {
863
+ className: "flex flex-wrap items-end justify-between gap-x-6 gap-y-3",
864
+ children: [/* @__PURE__ */ jsxs("div", {
865
+ className: "grid min-w-0 gap-0.5",
866
+ children: [/* @__PURE__ */ jsx(Heading, {
867
+ className: "type-title text-balance",
868
+ children: title
869
+ }), scope && /* @__PURE__ */ jsx("p", {
870
+ className: "type-body-sm text-muted-foreground",
871
+ children: scope
872
+ })]
873
+ }), actions && /* @__PURE__ */ jsx("div", {
874
+ className: "flex flex-wrap gap-2",
875
+ children: actions
876
+ })]
877
+ }), finding && /* @__PURE__ */ jsxs("div", {
878
+ className: "flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-y border-border py-3",
879
+ children: [/* @__PURE__ */ jsx("p", {
880
+ className: "max-w-prose min-w-0 type-body text-pretty",
881
+ children: finding.text
882
+ }), finding.action]
883
+ })]
884
+ });
885
+ }
886
+ // src/components/undo.tsx
887
+ /** Schedules a reversible removal to commit after `windowMs` unless undone. A second removal, or unmounting, commits the pending one. */
888
+ function useUndoable(windowMs = 8e3) {
889
+ const [pending, setPending] = React.useState(null);
890
+ const current = React.useRef(null);
891
+ const timer = React.useRef(void 0);
892
+ const set = React.useCallback((p) => {
893
+ current.current = p;
894
+ setPending(p);
895
+ }, []);
896
+ const schedule = React.useCallback((next) => {
897
+ window.clearTimeout(timer.current);
898
+ current.current?.commit();
899
+ set(next);
900
+ timer.current = window.setTimeout(() => {
901
+ current.current?.commit();
902
+ set(null);
903
+ }, windowMs);
904
+ }, [set, windowMs]);
905
+ const undo = React.useCallback(() => {
906
+ window.clearTimeout(timer.current);
907
+ set(null);
908
+ }, [set]);
909
+ React.useEffect(() => () => {
910
+ window.clearTimeout(timer.current);
911
+ current.current?.commit();
912
+ current.current = null;
913
+ }, []);
914
+ return {
915
+ pending,
916
+ schedule,
917
+ undo
918
+ };
919
+ }
920
+ /** The polite live region that shows a pending removal with an Undo button. */
921
+ function UndoBar({ pending, onUndo, inline = false }) {
922
+ return /* @__PURE__ */ jsx("div", {
923
+ "aria-live": "polite",
924
+ className: inline ? "flex" : "pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4",
925
+ children: pending && /* @__PURE__ */ jsxs("div", {
926
+ className: "pointer-events-auto flex max-w-lg items-center gap-4 rounded-overlay bg-foreground py-2 pr-2 pl-4 text-background shadow-overlay",
927
+ children: [/* @__PURE__ */ jsx("p", {
928
+ className: "min-w-0 type-body-sm",
929
+ children: pending.message
930
+ }), /* @__PURE__ */ jsx(Button, {
931
+ size: "sm",
932
+ variant: "ghost",
933
+ className: "text-background hover:bg-neutral-11",
934
+ onClick: onUndo,
935
+ children: "Undo"
936
+ })]
937
+ })
938
+ });
939
+ }
940
+ // src/components/done.tsx
941
+ /** The completion moment for a rare, earned finish: what happened (past tense, with its object), what happens next, and optionally a figure drawn from the person's own data. Focus moves to the heading. */
942
+ function Done({ title, next, figure, actions, focusOnMount = true, className }) {
943
+ const heading = React.useRef(null);
944
+ const titleId = React.useId();
945
+ React.useEffect(() => {
946
+ if (focusOnMount) heading.current?.focus();
947
+ }, [focusOnMount]);
948
+ return /* @__PURE__ */ jsxs("section", {
949
+ "aria-labelledby": titleId,
950
+ className: cn("grid gap-5", className),
951
+ children: [
952
+ /* @__PURE__ */ jsxs("div", {
953
+ className: "grid gap-2",
954
+ children: [/* @__PURE__ */ jsxs("h1", {
955
+ id: titleId,
956
+ ref: heading,
957
+ tabIndex: -1,
958
+ className: "flex items-start gap-2 type-title",
959
+ children: [/* @__PURE__ */ jsx(Check, {
960
+ "aria-hidden": true,
961
+ className: "mt-1 size-6 shrink-0 text-success"
962
+ }), title]
963
+ }), /* @__PURE__ */ jsx("div", {
964
+ className: "max-w-prose type-body text-muted-foreground",
965
+ children: next
966
+ })]
967
+ }),
968
+ figure,
969
+ actions && /* @__PURE__ */ jsx("div", {
970
+ className: "flex flex-wrap items-center gap-3",
971
+ children: actions
972
+ })
973
+ ]
974
+ });
975
+ }
976
+ // src/components/app-shell.tsx
977
+ /** The app frame: product name, side nav on the page ground (a scrollable row on phones), optional top bar, and the main region. */
978
+ function AppShell({ product, nav, navLabel = "Main", topBar, children, className }) {
979
+ return /* @__PURE__ */ jsxs("div", {
980
+ className: cn("min-h-dvh md:grid md:grid-cols-[14rem_minmax(0,1fr)]", className),
981
+ children: [/* @__PURE__ */ jsx("aside", {
982
+ className: "border-b border-border md:border-r md:border-b-0",
983
+ children: /* @__PURE__ */ jsxs("div", {
984
+ className: "flex items-center gap-4 px-4 py-3 md:block md:px-3 md:py-4",
985
+ children: [/* @__PURE__ */ jsx(Link, {
986
+ to: "/",
987
+ className: "shrink-0 rounded-control px-2 type-heading text-foreground no-underline",
988
+ children: product
989
+ }), /* @__PURE__ */ jsx(SideNav, {
990
+ items: nav,
991
+ label: navLabel,
992
+ className: "min-w-0 flex-1 md:mt-4"
993
+ })]
994
+ })
995
+ }), /* @__PURE__ */ jsxs("div", {
996
+ className: "min-w-0",
997
+ children: [topBar && /* @__PURE__ */ jsx("div", {
998
+ className: "flex items-center gap-3 border-b border-border px-4 py-2 md:px-8",
999
+ children: topBar
1000
+ }), /* @__PURE__ */ jsx("main", {
1001
+ className: "px-4 py-6 md:px-8 md:py-8",
1002
+ children
1003
+ })]
1004
+ })]
1005
+ });
1006
+ }
1007
+ /** Navigation links (React Router `NavLink`); the current route is a neutral pill with `aria-current`. */
1008
+ function SideNav({ items, label, className }) {
1009
+ return /* @__PURE__ */ jsx("nav", {
1010
+ "aria-label": label,
1011
+ className: cn("min-w-0", className),
1012
+ children: /* @__PURE__ */ jsx("ul", {
1013
+ className: "flex gap-1 overflow-x-auto md:grid md:overflow-visible",
1014
+ children: items.map(({ to, label, icon: Icon, end }) => /* @__PURE__ */ jsx("li", {
1015
+ className: "shrink-0",
1016
+ children: /* @__PURE__ */ jsxs(NavLink, {
1017
+ to,
1018
+ end,
1019
+ className: ({ isActive }) => cn("flex h-(--control-h-sm) items-center gap-2 rounded-control px-2 type-label whitespace-nowrap no-underline transition-state", isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"),
1020
+ children: [Icon && /* @__PURE__ */ jsx(Icon, {
1021
+ "aria-hidden": true,
1022
+ className: "size-4 shrink-0"
1023
+ }), label]
1024
+ })
1025
+ }, to))
1026
+ })
1027
+ });
1028
+ }
1029
+ /** Plain links separated by `/`; the last crumb is the current page. */
1030
+ function Breadcrumb({ items }) {
1031
+ return /* @__PURE__ */ jsx("nav", {
1032
+ "aria-label": "Breadcrumb",
1033
+ children: /* @__PURE__ */ jsx("ol", {
1034
+ className: "flex flex-wrap items-center gap-x-2 type-body-sm text-muted-foreground",
1035
+ children: items.map((item, i) => {
1036
+ const last = i === items.length - 1;
1037
+ return /* @__PURE__ */ jsxs("li", {
1038
+ className: "flex items-center gap-2",
1039
+ children: [item.to && !last ? /* @__PURE__ */ jsx(Link, {
1040
+ to: item.to,
1041
+ className: "text-muted-foreground underline hover:text-foreground",
1042
+ children: item.label
1043
+ }) : /* @__PURE__ */ jsx("span", {
1044
+ "aria-current": last ? "page" : void 0,
1045
+ className: cn(last && "text-foreground"),
1046
+ children: item.label
1047
+ }), !last && /* @__PURE__ */ jsx("span", {
1048
+ "aria-hidden": true,
1049
+ children: "/"
1050
+ })]
1051
+ }, `${item.label}-${i}`);
1052
+ })
1053
+ })
1054
+ });
1055
+ }
1056
+ // src/components/tabs.tsx
1057
+ /** Tabs whose selection lives in a URL search parameter (React Router), with the ARIA tabs keyboard model. */
1058
+ function Tabs({ param, items, label, children, className }) {
1059
+ const [params, setParams] = useSearchParams();
1060
+ const fallback = items[0]?.value ?? "";
1061
+ const raw = params.get(param);
1062
+ const value = items.some((i) => i.value === raw) ? raw : fallback;
1063
+ const baseId = React.useId();
1064
+ const refs = React.useRef([]);
1065
+ const select = (next, focus = false) => {
1066
+ setParams((p) => {
1067
+ const out = new URLSearchParams(p);
1068
+ if (next === fallback) out.delete(param);
1069
+ else out.set(param, next);
1070
+ return out;
1071
+ }, { replace: true });
1072
+ if (focus) refs.current[items.findIndex((i) => i.value === next)]?.focus();
1073
+ };
1074
+ const onKeyDown = (e, index) => {
1075
+ const last = items.length - 1;
1076
+ const to = e.key === "ArrowRight" ? index === last ? 0 : index + 1 : e.key === "ArrowLeft" ? index === 0 ? last : index - 1 : e.key === "Home" ? 0 : e.key === "End" ? last : void 0;
1077
+ if (to === void 0) return;
1078
+ e.preventDefault();
1079
+ select(items[to].value, true);
1080
+ };
1081
+ return /* @__PURE__ */ jsxs("div", {
1082
+ className: cn("grid gap-4", className),
1083
+ children: [/* @__PURE__ */ jsx("div", {
1084
+ role: "tablist",
1085
+ "aria-label": label,
1086
+ className: "flex gap-4 overflow-x-auto border-b border-border",
1087
+ children: items.map((item, i) => {
1088
+ const selected = item.value === value;
1089
+ return /* @__PURE__ */ jsx("button", {
1090
+ ref: (el) => {
1091
+ refs.current[i] = el;
1092
+ },
1093
+ type: "button",
1094
+ role: "tab",
1095
+ id: `${baseId}-tab-${item.value}`,
1096
+ "aria-selected": selected,
1097
+ "aria-controls": `${baseId}-panel`,
1098
+ tabIndex: selected ? 0 : -1,
1099
+ onClick: () => select(item.value),
1100
+ onKeyDown: (e) => onKeyDown(e, i),
1101
+ className: cn("-mb-px cursor-pointer border-b-2 py-2 type-label whitespace-nowrap transition-state", selected ? "border-foreground font-semibold text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"),
1102
+ children: item.label
1103
+ }, item.value);
1104
+ })
1105
+ }), /* @__PURE__ */ jsx("div", {
1106
+ role: "tabpanel",
1107
+ id: `${baseId}-panel`,
1108
+ "aria-labelledby": `${baseId}-tab-${value}`,
1109
+ tabIndex: 0,
1110
+ children: children(value)
1111
+ })]
1112
+ });
1113
+ }
1114
+ export { AppShell, AsyncButton, Breadcrumb, Button, ChoiceGroup, DataTable, DataView, Done, EmptyState, Field, HttpError, IconButton, Input, LineChart, Metric, MetricStrip, NBSP, NativeSelect, PageHeader, SideNav, Skeleton, Status, StrataQueryProvider, TableSkeleton, Tabs, Textarea, UndoBar, VirtualList, buttonVariants, cn, createFormat, createQueryClient, describeHttpError, fetchJson, format, parseSort, serialiseSort, toLoad, typeSteps, useAsyncAction, useLoad, useUndoable };
1115
+
1116
+ //# sourceMappingURL=index.js.map