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/LICENSE +21 -0
- package/README.md +116 -2
- package/dist/components/app-shell.d.ts +32 -0
- package/dist/components/async-button.d.ts +18 -0
- package/dist/components/button.d.ts +18 -0
- package/dist/components/data-table.d.ts +42 -0
- package/dist/components/data-view.d.ts +33 -0
- package/dist/components/done.d.ts +14 -0
- package/dist/components/field.d.ts +34 -0
- package/dist/components/line-chart.d.ts +36 -0
- package/dist/components/metric.d.ts +17 -0
- package/dist/components/page-header.d.ts +14 -0
- package/dist/components/status.d.ts +9 -0
- package/dist/components/tabs.d.ts +16 -0
- package/dist/components/undo.d.ts +20 -0
- package/dist/components/virtual-list.d.ts +12 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +1116 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/format.d.ts +30 -0
- package/dist/lib/icon.d.ts +6 -0
- package/dist/lib/utils.d.ts +4 -0
- package/dist/query/index.d.ts +27 -0
- package/dist/query-DZVmYJSx.js +72 -0
- package/dist/query-DZVmYJSx.js.map +1 -0
- package/dist/strata.css +181 -0
- package/dist/testing/index.d.ts +70 -0
- package/dist/testing.js +32 -0
- package/dist/testing.js.map +1 -0
- package/package.json +127 -4
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
|