@cosmicdrift/kumiko-renderer-web 0.349.0 → 0.351.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/dashboard-body.js +131 -43
- package/dist/app/nav.js +19 -10
- package/dist/primitives/date-field.js +1 -1
- package/dist/primitives/date-parse.d.ts +1 -1
- package/dist/primitives/date-parse.js +1 -1
- package/dist/primitives/index.js +21 -10
- package/dist/primitives/lightbox.js +2 -2
- package/dist/widgets/charts.d.ts +18 -3
- package/dist/widgets/charts.js +77 -22
- package/dist/widgets/progress-list.d.ts +2 -0
- package/dist/widgets/progress-list.js +1 -1
- package/dist/widgets/stat.d.ts +4 -1
- package/dist/widgets/stat.js +11 -8
- package/package.json +7 -7
- package/src/changes.json +17 -0
|
@@ -6,7 +6,7 @@ import { EmbeddedFormProvider } from "../primitives/index.js";
|
|
|
6
6
|
import { PageSection } from "../primitives/layout.js";
|
|
7
7
|
import { formatMoney } from "../primitives/money-input.js";
|
|
8
8
|
import { Skeleton } from "../ui/skeleton.js";
|
|
9
|
-
import { SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
|
|
9
|
+
import { chartToneColor, SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
|
|
10
10
|
import { DashboardListTable } from "../widgets/dashboard-list.js";
|
|
11
11
|
import { FeedList } from "../widgets/feed-list.js";
|
|
12
12
|
import { ModeSwitch } from "../widgets/mode-switch.js";
|
|
@@ -116,8 +116,8 @@ function resolveDashboardText(value, translate, formats) {
|
|
|
116
116
|
return resolveI18nText(value, translate, formats, 1);
|
|
117
117
|
return undefined;
|
|
118
118
|
}
|
|
119
|
-
function PanelShell({ testId, title, subtitle, children, }) {
|
|
120
|
-
return (_jsxs("section", { "data-testid": testId, className: "flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border-row px-4", children: [_jsx("h2", { className: "truncate text-sm font-semibold", children: title }), subtitle !== undefined && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: subtitle }))] }), _jsx("div", { className: "grow p-4", children: children })] }));
|
|
119
|
+
function PanelShell({ testId, title, subtitle, action, children, }) {
|
|
120
|
+
return (_jsxs("section", { "data-testid": testId, className: "flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border-row px-4", children: [_jsx("h2", { className: "truncate text-sm font-semibold", children: title }), subtitle !== undefined && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: subtitle })), action !== undefined && _jsx("div", { className: "ml-auto shrink-0", children: action })] }), _jsx("div", { className: "grow p-4", children: children })] }));
|
|
121
121
|
}
|
|
122
122
|
function PanelError({ label, error, onRetry, testId, }) {
|
|
123
123
|
const { Button } = usePrimitives();
|
|
@@ -133,7 +133,7 @@ function PanelSkeleton({ shape }) {
|
|
|
133
133
|
}
|
|
134
134
|
return (_jsx("output", { "aria-label": t("kumiko.widget.loading"), className: "flex h-40 items-end gap-1", children: SKELETON_BAR_HEIGHTS.map((height) => (_jsx(Skeleton, { className: "flex-1", style: { height } }, height))) }));
|
|
135
135
|
}
|
|
136
|
-
function QueryPanel({ panel, label, subtitle, screenParams, translate, skeleton, isEmpty, children, }) {
|
|
136
|
+
function QueryPanel({ panel, label, subtitle, action, screenParams, translate, skeleton, isEmpty, children, }) {
|
|
137
137
|
const { data, error, loading, refetch } = useQuery(panel.query, panelPayload(panel, screenParams), { live: true });
|
|
138
138
|
let body;
|
|
139
139
|
if (loading && data === null) {
|
|
@@ -148,7 +148,7 @@ function QueryPanel({ panel, label, subtitle, screenParams, translate, skeleton,
|
|
|
148
148
|
else {
|
|
149
149
|
body = children(data);
|
|
150
150
|
}
|
|
151
|
-
return (_jsx(PanelShell, { testId: `dashboard-panel-${panel.id}`, title: label, subtitle: subtitle, children: body }));
|
|
151
|
+
return (_jsx(PanelShell, { testId: `dashboard-panel-${panel.id}`, title: label, subtitle: subtitle, action: action, children: body }));
|
|
152
152
|
}
|
|
153
153
|
function isSparkPoint(value) {
|
|
154
154
|
return (typeof value === "object" &&
|
|
@@ -207,10 +207,11 @@ function StatPanelBody({ panel, label, screenId, screenParams, translate, varian
|
|
|
207
207
|
const spark = readSparkline(panel, record);
|
|
208
208
|
const testId = `dashboard-panel-${panel.id}`;
|
|
209
209
|
const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
|
|
210
|
+
const icon = Icon !== undefined ? (_jsx(Icon, { entityName: screenId, entityId: null, screenId: screenId, filterParams: screenParams.filterParams })) : undefined;
|
|
210
211
|
if (variant === "strip") {
|
|
211
|
-
return (_jsx(StatStripCell, { label: label, value: value, tone: tone ?? "default", ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
212
|
+
return (_jsx(StatStripCell, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
212
213
|
}
|
|
213
|
-
return (_jsx(StatCard, { icon:
|
|
214
|
+
return (_jsx(StatCard, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
214
215
|
}
|
|
215
216
|
function readDelta(panel, record) {
|
|
216
217
|
if (panel.deltaField === undefined || panel.deltaDirectionField === undefined)
|
|
@@ -229,13 +230,41 @@ function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }
|
|
|
229
230
|
if (label === undefined) {
|
|
230
231
|
return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "strip" }, stat.id))) }));
|
|
231
232
|
}
|
|
232
|
-
return (_jsx(SectionCard, { title: label, testId: testId, children: _jsx("section", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
233
|
+
return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
233
234
|
}
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
235
|
+
function markerKindColor(kind) {
|
|
236
|
+
if (kind === undefined)
|
|
237
|
+
return undefined;
|
|
238
|
+
if (kind.color !== undefined)
|
|
239
|
+
return kind.color;
|
|
240
|
+
return kind.tone !== undefined ? chartToneColor(kind.tone) : undefined;
|
|
241
|
+
}
|
|
242
|
+
function resolveMarkers(markers, translate, formats, markerKinds) {
|
|
243
|
+
return markers?.map((marker) => {
|
|
244
|
+
const color = typeof marker.kind === "string" ? markerKindColor(markerKinds?.[marker.kind]) : undefined;
|
|
245
|
+
return {
|
|
246
|
+
atMs: marker.atMs,
|
|
247
|
+
label: resolveDashboardText(marker.label, translate, formats) ?? "",
|
|
248
|
+
...(color !== undefined && { color }),
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function addUtcMonths(atMs, months) {
|
|
253
|
+
const date = new Date(atMs);
|
|
254
|
+
date.setUTCMonth(date.getUTCMonth() + months);
|
|
255
|
+
return date.getTime();
|
|
256
|
+
}
|
|
257
|
+
// Same window as a "1/3/5 years" switch over a plan: `months` ahead of today,
|
|
258
|
+
// pulled back when the data ends earlier, never before the first bucket.
|
|
259
|
+
function rangeWindow(months, anchorMs, firstMs, lastMs) {
|
|
260
|
+
const endMs = Math.min(lastMs, addUtcMonths(anchorMs, months));
|
|
261
|
+
return { startMs: Math.max(firstMs, addUtcMonths(endMs, -months)), endMs };
|
|
262
|
+
}
|
|
263
|
+
function pointsWithin(points, window) {
|
|
264
|
+
return points.filter((p) => p.atMs >= window.startMs && p.atMs <= window.endMs);
|
|
265
|
+
}
|
|
266
|
+
function selectedRangeMonths(ranges, selected) {
|
|
267
|
+
return ranges?.options.find((option) => option.value === selected)?.months;
|
|
239
268
|
}
|
|
240
269
|
// A plain bar chart ("changes per day") ships `points` without `series`.
|
|
241
270
|
const SINGLE_SERIES_KEY = "value";
|
|
@@ -272,18 +301,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
272
301
|
const t = useTranslation();
|
|
273
302
|
const todayLabel = t("kumiko.dashboard.today");
|
|
274
303
|
const tones = panel.seriesTones;
|
|
304
|
+
const colors = panel.seriesColors;
|
|
275
305
|
const testId = `dashboard-chart-${panel.id}`;
|
|
276
|
-
|
|
306
|
+
const ranges = panel.chart === "stacked-area" ? panel.ranges : undefined;
|
|
307
|
+
const [selectedRange, setSelectedRange] = useState(ranges?.default);
|
|
308
|
+
const rangeSwitch = ranges !== undefined && selectedRange !== undefined ? (_jsx(ModeSwitch, { value: selectedRange, options: ranges.options.map((option) => ({
|
|
309
|
+
value: option.value,
|
|
310
|
+
label: translate(option.label),
|
|
311
|
+
})), onChange: setSelectedRange, ariaLabel: label, variant: "pill", testId: `dashboard-chart-range-${panel.id}` })) : undefined;
|
|
312
|
+
return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, action: rangeSwitch, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data) => {
|
|
277
313
|
const series = chartSeries(data, label, translate);
|
|
278
314
|
const { startMs, endMs } = windowOf(data, series);
|
|
279
|
-
const markers = resolveMarkers(data.markers, translate, formats);
|
|
315
|
+
const markers = resolveMarkers(data.markers, translate, formats, panel.markerKinds);
|
|
280
316
|
const formatValue = formats.formatPanelValue(panel.valueFormat);
|
|
281
317
|
if (panel.chart === "segment-bars") {
|
|
282
318
|
return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
|
|
283
319
|
...row,
|
|
284
320
|
label: translate(row.label),
|
|
285
321
|
segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
|
|
286
|
-
})), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
322
|
+
})), tones: tones, colors: colors, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
287
323
|
}
|
|
288
324
|
if (panel.chart === "stacked-bars") {
|
|
289
325
|
const times = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
@@ -293,11 +329,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
293
329
|
const prev = all[i - 1];
|
|
294
330
|
return prev === undefined ? [] : [atMs - prev];
|
|
295
331
|
}), DAY_MS);
|
|
296
|
-
return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, testId: testId }));
|
|
332
|
+
return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, colors: colors, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, testId: testId }));
|
|
297
333
|
}
|
|
298
334
|
if (panel.chart === "stacked-area") {
|
|
299
|
-
|
|
300
|
-
|
|
335
|
+
const lines = (data.lines ?? []).map((line) => ({
|
|
336
|
+
...line,
|
|
337
|
+
label: translate(line.label),
|
|
338
|
+
}));
|
|
339
|
+
const todayMs = data.todayMs ?? undefined;
|
|
340
|
+
const months = selectedRangeMonths(ranges, selectedRange);
|
|
341
|
+
const bucketTimes = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
342
|
+
const window = months === undefined || bucketTimes.length === 0
|
|
343
|
+
? { startMs, endMs }
|
|
344
|
+
: rangeWindow(months, todayMs ?? Math.max(...bucketTimes), Math.min(...bucketTimes), Math.max(...bucketTimes));
|
|
345
|
+
const isWindowed = months !== undefined;
|
|
346
|
+
return (_jsx(StackedAreaChart, { series: isWindowed
|
|
347
|
+
? series.map((s) => ({ ...s, points: pointsWithin(s.points, window) }))
|
|
348
|
+
: series, lines: isWindowed
|
|
349
|
+
? lines.map((line) => ({ ...line, points: pointsWithin(line.points, window) }))
|
|
350
|
+
: lines, windowStartMs: window.startMs, windowEndMs: window.endMs, ...(todayMs !== undefined && { todayMs }), tones: tones, colors: colors, markers: isWindowed && markers !== undefined ? pointsWithin(markers, window) : markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formatValue, formatMarkerTime: formats.formatDay, scrollable: panel.scrollable === true, showLegendTotals: panel.legendTotals !== false, emptyContent: _jsx(EmptyState, { title: translate(panel.emptyLabel ?? "kumiko.list.no-entries") }), testId: testId }));
|
|
301
351
|
}
|
|
302
352
|
return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
|
|
303
353
|
yAxis: { ticks: 3, format: formatValue },
|
|
@@ -336,12 +386,16 @@ function FeedPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
336
386
|
function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
|
|
337
387
|
const formats = useDashboardFormats();
|
|
338
388
|
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
|
|
339
|
-
const rows = (data.rows ?? []).map((row, i) =>
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
389
|
+
const rows = (data.rows ?? []).map((row, i) => {
|
|
390
|
+
const sub = resolveDashboardText(row.sub, translate, formats);
|
|
391
|
+
return {
|
|
392
|
+
id: String(i),
|
|
393
|
+
label: resolveDashboardText(row.label, translate, formats) ?? "—",
|
|
394
|
+
value: resolveDashboardText(row.value, translate, formats) ?? "—",
|
|
395
|
+
fraction: row.fraction,
|
|
396
|
+
...(sub !== undefined && { sub }),
|
|
397
|
+
};
|
|
398
|
+
});
|
|
345
399
|
return _jsx(ProgressList, { rows: rows });
|
|
346
400
|
} }));
|
|
347
401
|
}
|
|
@@ -439,33 +493,67 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
|
439
493
|
return (_jsx(ProgressListPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
|
|
440
494
|
}
|
|
441
495
|
const VisibilityQueryContext = createContext(new Map());
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
496
|
+
const NO_GATE_PAYLOAD = {};
|
|
497
|
+
function visibilityGate(query, payload) {
|
|
498
|
+
return { key: `${query}|${JSON.stringify(payload)}`, query, payload };
|
|
499
|
+
}
|
|
500
|
+
// Embedded screens carry no screen filter; in-grid panels pass the filter
|
|
501
|
+
// (unless ignoreScreenFilter, never for a stat-group) and the time range.
|
|
502
|
+
function panelVisibilityGate(panel, screenParams) {
|
|
503
|
+
if (panel.kind === "custom" || panel.visibleWhen === undefined)
|
|
504
|
+
return undefined;
|
|
505
|
+
if (panel.kind === "screen")
|
|
506
|
+
return visibilityGate(panel.visibleWhen.query, NO_GATE_PAYLOAD);
|
|
507
|
+
const dropsFilter = panel.kind !== "stat-group" && panel.ignoreScreenFilter === true;
|
|
508
|
+
return visibilityGate(panel.visibleWhen.query, {
|
|
509
|
+
...(dropsFilter ? {} : screenParams.filterParams),
|
|
510
|
+
...screenParams.rangeParams,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// One live query per distinct gate (query + payload), shared by every panel
|
|
514
|
+
// that gates on it — N panels on the same status query would otherwise each
|
|
515
|
+
// open their own request, SSE subscription and refetch per event.
|
|
516
|
+
function VisibilityQueryScope({ gate, children, }) {
|
|
446
517
|
const parent = useContext(VisibilityQueryContext);
|
|
447
|
-
const result = useQuery(query,
|
|
448
|
-
|
|
518
|
+
const result = useQuery(gate.query, gate.payload, {
|
|
519
|
+
live: true,
|
|
520
|
+
});
|
|
521
|
+
const value = useMemo(() => new Map(parent).set(gate.key, result), [parent, gate.key, result]);
|
|
449
522
|
return (_jsx(VisibilityQueryContext.Provider, { value: value, children: children }));
|
|
450
523
|
}
|
|
451
|
-
function VisibilityQueryScopes({
|
|
452
|
-
const [first, ...rest] =
|
|
524
|
+
function VisibilityQueryScopes({ gates, children, }) {
|
|
525
|
+
const [first, ...rest] = gates;
|
|
453
526
|
if (first === undefined)
|
|
454
527
|
return children;
|
|
455
|
-
return (_jsx(VisibilityQueryScope, {
|
|
528
|
+
return (_jsx(VisibilityQueryScope, { gate: first, children: _jsx(VisibilityQueryScopes, { gates: rest, children: children }) }));
|
|
456
529
|
}
|
|
457
|
-
function
|
|
458
|
-
const
|
|
530
|
+
function distinctVisibilityGates(panels, screenParams) {
|
|
531
|
+
const gates = new Map();
|
|
459
532
|
for (const panel of panels) {
|
|
460
|
-
|
|
461
|
-
|
|
533
|
+
const gate = panelVisibilityGate(panel, screenParams);
|
|
534
|
+
if (gate !== undefined)
|
|
535
|
+
gates.set(gate.key, gate);
|
|
536
|
+
}
|
|
537
|
+
return [...gates.values()];
|
|
538
|
+
}
|
|
539
|
+
function GatedGridCell({ panel, screenParams, label, className, children, }) {
|
|
540
|
+
const gate = panelVisibilityGate(panel, screenParams);
|
|
541
|
+
const visibility = useContext(VisibilityQueryContext).get(gate?.key ?? "");
|
|
542
|
+
const visibleWhen = panel.kind === "custom" ? undefined : panel.visibleWhen;
|
|
543
|
+
if (visibleWhen !== undefined) {
|
|
544
|
+
const verdict = evalVisibleWhen(visibleWhen, visibility);
|
|
545
|
+
if (verdict === "error" && visibility?.error) {
|
|
546
|
+
return (_jsx("div", { className: className, "data-testid": `dashboard-panel-${panel.id}`, children: _jsx(PanelError, { label: label, error: visibility.error, onRetry: () => void visibility.refetch() }) }));
|
|
462
547
|
}
|
|
548
|
+
// Loading renders nothing too: a skeleton that then vanishes flickers.
|
|
549
|
+
if (verdict !== "visible")
|
|
550
|
+
return null;
|
|
463
551
|
}
|
|
464
|
-
return
|
|
552
|
+
return _jsx("div", { className: className, children: children });
|
|
465
553
|
}
|
|
466
554
|
function ScreenPanelTile({ panel, featureName, translate, }) {
|
|
467
555
|
const visibleWhen = panel.visibleWhen;
|
|
468
|
-
const visibility = useContext(VisibilityQueryContext).get(visibleWhen
|
|
556
|
+
const visibility = useContext(VisibilityQueryContext).get(visibleWhen !== undefined ? visibilityGate(visibleWhen.query, NO_GATE_PAYLOAD).key : "");
|
|
469
557
|
const target = useEmbeddedScreen(featureName, panel.screen);
|
|
470
558
|
if (target === undefined)
|
|
471
559
|
return null;
|
|
@@ -502,7 +590,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
502
590
|
const { params: rangeParams, control: rangeControl } = useTimeRange(screen, effectiveTranslate);
|
|
503
591
|
const screenParams = { filterParams, rangeParams };
|
|
504
592
|
const loadedAtMs = useLoadedAt(screenParams);
|
|
505
|
-
const
|
|
593
|
+
const visibilityGates = distinctVisibilityGates(screen.panels, screenParams);
|
|
506
594
|
const scope = screen.scope;
|
|
507
595
|
// A raw description is agent-facing prose; only an i18n key is user-facing copy.
|
|
508
596
|
const translatedDescription = screen.description !== undefined ? effectiveTranslate(screen.description) : undefined;
|
|
@@ -510,13 +598,13 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
510
598
|
const scopeBadge = scope !== undefined ? (_jsx(StatusBadge, { tone: "accent", testId: `dashboard-${screen.id}-scope`, children: effectiveTranslate(scope.badge) })) : null;
|
|
511
599
|
// The shell header owns the title; the badge sits right after it there.
|
|
512
600
|
const badgeInHeader = scopeBadge !== null && headerSlotAvailable && PageHeader !== undefined;
|
|
513
|
-
return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx(VisibilityQueryScopes, {
|
|
601
|
+
return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx(VisibilityQueryScopes, { gates: visibilityGates, children: _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
|
|
514
602
|
if (panel.kind === "screen") {
|
|
515
603
|
return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
|
|
516
604
|
}
|
|
517
605
|
const label = panel.kind === "custom" || panel.label === undefined
|
|
518
606
|
? undefined
|
|
519
607
|
: effectiveTranslate(panel.label);
|
|
520
|
-
return (_jsx(
|
|
608
|
+
return (_jsx(GatedGridCell, { panel: panel, screenParams: screenParams, label: label ?? panel.id, className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screen.id, screenParams: screenParams, translate: effectiveTranslate }) }, panel.id));
|
|
521
609
|
}) }) })] }));
|
|
522
610
|
}
|
package/dist/app/nav.js
CHANGED
|
@@ -121,25 +121,34 @@ function applySearchParamUpdates(updates) {
|
|
|
121
121
|
for (const l of listeners)
|
|
122
122
|
l();
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
// undefined = the caller passed no search params; "" = an explicitly empty query.
|
|
125
|
+
function formatSearch(options) {
|
|
126
|
+
if (options?.searchParams === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
const query = new URLSearchParams(options.searchParams).toString();
|
|
129
|
+
return query === "" ? "" : `?${query}`;
|
|
130
|
+
}
|
|
131
|
+
function pushPath(path, search) {
|
|
125
132
|
if (typeof window === "undefined")
|
|
126
133
|
return;
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
134
|
+
// Repeated navigate() calls to the same URL must not flood the history.
|
|
135
|
+
// Without explicit search params the current query counts as unchanged.
|
|
136
|
+
const unchanged = window.location.pathname === path &&
|
|
137
|
+
(search === undefined || window.location.search === search);
|
|
138
|
+
if (unchanged)
|
|
130
139
|
return;
|
|
131
|
-
window.history.pushState(null, "", path);
|
|
140
|
+
window.history.pushState(null, "", `${path}${search ?? ""}`);
|
|
132
141
|
for (const l of listeners)
|
|
133
142
|
l();
|
|
134
143
|
}
|
|
135
|
-
function replacePath(path) {
|
|
144
|
+
function replacePath(path, search) {
|
|
136
145
|
if (typeof window === "undefined")
|
|
137
146
|
return;
|
|
138
147
|
// No path-change short-circuit here: callers explicitly chose replace
|
|
139
148
|
// to avoid creating a history entry, even when the URL is identical.
|
|
140
149
|
// (pushPath skips no-op pushes; replacePath honors the call so the
|
|
141
150
|
// entry-stack semantics stay predictable.)
|
|
142
|
-
window.history.replaceState(null, "", path);
|
|
151
|
+
window.history.replaceState(null, "", `${path}${search ?? ""}`);
|
|
143
152
|
for (const l of listeners)
|
|
144
153
|
l();
|
|
145
154
|
}
|
|
@@ -197,9 +206,9 @@ export function useBrowserNavApi(options) {
|
|
|
197
206
|
const toPath = (target) => prependBasePath(formatPath(inCurrentWorkspace(resolveTarget(features, target))), basePath);
|
|
198
207
|
return {
|
|
199
208
|
route,
|
|
200
|
-
navigate: (target) => pushPath(toPath(target)),
|
|
201
|
-
replace: (target) => replacePath(toPath(target)),
|
|
202
|
-
hrefFor: (target) => toPath(target)
|
|
209
|
+
navigate: (target, navigateOptions) => pushPath(toPath(target), formatSearch(navigateOptions)),
|
|
210
|
+
replace: (target, navigateOptions) => replacePath(toPath(target), formatSearch(navigateOptions)),
|
|
211
|
+
hrefFor: (target, navigateOptions) => `${toPath(target)}${formatSearch(navigateOptions) ?? ""}`,
|
|
203
212
|
searchParams,
|
|
204
213
|
setSearchParams: applySearchParamUpdates,
|
|
205
214
|
};
|
|
@@ -8,8 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// date part next to the time input. So `date` and `timestamp` share the
|
|
9
9
|
// same typing/navigation UX instead of two diverging primitives (#369).
|
|
10
10
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
11
12
|
import { useState } from "react";
|
|
12
|
-
import { Temporal } from "temporal-polyfill";
|
|
13
13
|
import { cn } from "../lib/cn.js";
|
|
14
14
|
import { CalendarPopover } from "./calendar-popover.js";
|
|
15
15
|
import { formatDateForInput, formatDatePlaceholder, guessLocale, parseIso, parseTypedDate, toIso, } from "./date-parse.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Temporal } from "temporal
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
2
|
export declare function guessLocale(): string;
|
|
3
3
|
export declare function parseIso(v: string): Temporal.PlainDate | undefined;
|
|
4
4
|
export declare function toIso(d: Temporal.PlainDate): string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// zone. TZ/wall-clock conversion for timestamp fields deliberately stays
|
|
5
5
|
// in timestamp-input.tsx (wire boundary, own test); this file is pure
|
|
6
6
|
// calendar date.
|
|
7
|
-
import { Temporal } from "temporal
|
|
7
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
8
8
|
// Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
|
|
9
9
|
// polyfill so `instanceof` checks match values crossing package boundaries;
|
|
10
10
|
// falls back to the polyfill where native support is absent.
|
package/dist/primitives/index.js
CHANGED
|
@@ -504,7 +504,7 @@ const EXPAND_TOGGLE_CELL_CLASS = "w-10 px-1! py-0!";
|
|
|
504
504
|
const EXPANSION_CELL_CLASS = "whitespace-normal bg-muted p-0! align-top md:[&_td:last-child]:bg-muted";
|
|
505
505
|
const EXPANSION_CONTENT_CLASS = "pt-1 pr-4 pb-3.5 pl-[52px]";
|
|
506
506
|
function RowExpandToggle({ expanded, controlsId, label, onToggle, testId, }) {
|
|
507
|
-
return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
|
|
507
|
+
return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0 max-md:size-11", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
|
|
508
508
|
e.stopPropagation();
|
|
509
509
|
onToggle();
|
|
510
510
|
}, onKeyDown: (e) => e.stopPropagation(), children: _jsx(ChevronRight, { className: cn("size-4 transition-transform", expanded && "rotate-90"), "aria-hidden": "true" }) }));
|
|
@@ -670,7 +670,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
670
670
|
e.preventDefault();
|
|
671
671
|
e.stopPropagation();
|
|
672
672
|
onRowClick(row);
|
|
673
|
-
}, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink }) }))] }, row.id));
|
|
673
|
+
}, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink, compact: true }) }))] }, row.id));
|
|
674
674
|
if (!isRowExpandable)
|
|
675
675
|
return rowItem;
|
|
676
676
|
// The expansion is a sibling <li>, outside the row's click target, so
|
|
@@ -764,7 +764,7 @@ function primaryRowAction(actions) {
|
|
|
764
764
|
// The isVisible filter runs here; an action hidden for a given row never
|
|
765
765
|
// reaches render. If every action is hidden, the cell stays empty (no
|
|
766
766
|
// phantom column).
|
|
767
|
-
function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, }) {
|
|
767
|
+
function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, compact = false, }) {
|
|
768
768
|
const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
|
|
769
769
|
if (visible.length === 0)
|
|
770
770
|
return null;
|
|
@@ -774,12 +774,12 @@ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, })
|
|
|
774
774
|
// otherwise "inline" is exactly the wall-to-wall text-button problem
|
|
775
775
|
// this feature exists to fix. An author-set `display` takes that choice over.
|
|
776
776
|
const iconOnly = !hasExplicitDisplay && shouldRenderActionsIconOnly(visible);
|
|
777
|
-
return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly }, a.id))) }));
|
|
777
|
+
return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly, compact: compact }, a.id))) }));
|
|
778
778
|
}
|
|
779
779
|
if (hasExplicitDisplay) {
|
|
780
780
|
const inline = visible.filter((a) => a.display !== undefined);
|
|
781
781
|
const rest = visible.filter((a) => a.display === undefined);
|
|
782
|
-
return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
|
|
782
|
+
return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
|
|
783
783
|
}
|
|
784
784
|
if (rowIsLink) {
|
|
785
785
|
return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
|
|
@@ -890,12 +890,20 @@ function useRowActionTrigger(row) {
|
|
|
890
890
|
};
|
|
891
891
|
return { busy, triggerNow };
|
|
892
892
|
}
|
|
893
|
-
|
|
893
|
+
// An icon-only form without a resolvable icon would render an empty button.
|
|
894
|
+
// `compact` is the narrow card layout, where "responsive" collapses to its icon.
|
|
895
|
+
function effectiveRowActionDisplay(display, hasIcon, compact) {
|
|
896
|
+
if (display === "responsive")
|
|
897
|
+
return hasIcon && compact ? "icon" : "button";
|
|
898
|
+
if (display === "icon" && !hasIcon)
|
|
899
|
+
return "link";
|
|
900
|
+
return display;
|
|
901
|
+
}
|
|
902
|
+
function RowActionButton({ row, action, iconOnly = false, asLink = false, compact = false, }) {
|
|
894
903
|
const { busy, triggerNow } = useRowActionTrigger(row);
|
|
895
904
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
896
905
|
const resolvedIcon = actionIconFor(action.icon);
|
|
897
|
-
|
|
898
|
-
const display = action.display === "icon" && resolvedIcon === undefined ? "link" : action.display;
|
|
906
|
+
const display = effectiveRowActionDisplay(action.display, resolvedIcon !== undefined, compact);
|
|
899
907
|
const isBorderedButton = display === "button";
|
|
900
908
|
const showIconOnly = display === "icon" || (display === undefined && iconOnly && resolvedIcon !== undefined);
|
|
901
909
|
const linkLike = display === "link" || (display === undefined && asLink);
|
|
@@ -924,7 +932,7 @@ function RowActionButton({ row, action, iconOnly = false, asLink = false, }) {
|
|
|
924
932
|
? "h-8 rounded-md px-3 font-medium max-md:h-11"
|
|
925
933
|
: linkLike
|
|
926
934
|
? "h-7 rounded-md px-2.5 max-md:h-11"
|
|
927
|
-
: "h-8 rounded-sm", showIconOnly ? "w-8" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
|
|
935
|
+
: "h-8 rounded-sm max-md:h-11", showIconOnly ? "w-8 max-md:w-11" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
|
|
928
936
|
}
|
|
929
937
|
// Kebab dropdown for >2 actions, one inline confirm dialog per item like
|
|
930
938
|
// the inline-button path. Menu is controlled so it closes explicitly on
|
|
@@ -1820,7 +1828,10 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
|
|
|
1820
1828
|
s.headerActions !== undefined ? (_jsxs("div", { className: cn("flex flex-col gap-3 px-[var(--card-padding)] pt-6 pb-4", fillHeight && "shrink-0"), children: [(s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined) && (_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [s.title !== undefined && (_jsx("h3", { className: "text-base font-semibold leading-none tracking-tight", children: s.title })), s.subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: s.subtitle }))] }), s.headerActions] })), s.headerContent] })) : null;
|
|
1821
1829
|
const header = s.header ?? defaultHeader;
|
|
1822
1830
|
const hasHeader = header !== null && header !== undefined;
|
|
1823
|
-
const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
|
|
1831
|
+
const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
|
|
1832
|
+
// An unframed card has no radius to clip; overflow-hidden would also
|
|
1833
|
+
// zero its flex-item min-height and cut off content inside a scroller.
|
|
1834
|
+
(framed || fillHeight) && "overflow-hidden",
|
|
1824
1835
|
// Same "no flex-1" reasoning as DefaultForm's own fillHeight card
|
|
1825
1836
|
// (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
|
|
1826
1837
|
// an ancestor is itself height-constrained.
|
|
@@ -6,7 +6,7 @@ import { cn } from "../lib/cn.js";
|
|
|
6
6
|
import { ModalShell } from "./modal-shell.js";
|
|
7
7
|
const NAV_BUTTON_CLASS = "absolute top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full border border-border bg-card text-foreground opacity-80 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring";
|
|
8
8
|
export function DefaultLightbox(props) {
|
|
9
|
-
const { open, onOpenChange, testId } = props;
|
|
9
|
+
const { open, onOpenChange, testId, actions, showPosition = true } = props;
|
|
10
10
|
const t = useTranslation();
|
|
11
11
|
const images = "images" in props ? props.images : [{ src: props.src, alt: props.alt }];
|
|
12
12
|
const onIndexChange = "images" in props ? props.onIndexChange : undefined;
|
|
@@ -31,5 +31,5 @@ export function DefaultLightbox(props) {
|
|
|
31
31
|
goTo(index - 1);
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
-
return (_jsxs(ModalShell, { open: open, onOpenChange: onOpenChange, testId: testId, closeLabel: t("kumiko.dialog.close"), noAriaDescription: true, ...(canNavigate && { onContentKeyDown: handleKeyDown }), contentClassName: cn("border-0 bg-transparent p-0 shadow-none", "max-w-[95vw] max-h-[90vh] w-auto"), children: [_jsx(DialogPrimitive.Title, { className: "sr-only", children: current.alt }), _jsx("img", { src: current.src, alt: current.alt, className: "block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg" }), canNavigate && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.previous"), onClick: () => goTo(index - 1), className: cn(NAV_BUTTON_CLASS, "left-2"), children: _jsx(ChevronLeft, { className: "size-5" }) }), _jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.next"), onClick: () => goTo(index + 1), className: cn(NAV_BUTTON_CLASS, "right-2"), children: _jsx(ChevronRight, { className: "size-5" }) }), _jsx("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-card px-3 py-1 text-xs text-foreground shadow-sm", children: t("kumiko.lightbox.position", { current: index + 1, total }) })] }))] }));
|
|
34
|
+
return (_jsxs(ModalShell, { open: open, onOpenChange: onOpenChange, testId: testId, closeLabel: t("kumiko.dialog.close"), noAriaDescription: true, ...(canNavigate && { onContentKeyDown: handleKeyDown }), contentClassName: cn("border-0 bg-transparent p-0 shadow-none", "max-w-[95vw] max-h-[90vh] w-auto"), children: [_jsx(DialogPrimitive.Title, { className: "sr-only", children: current.alt }), _jsx("img", { src: current.src, alt: current.alt, className: "block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg" }), canNavigate && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.previous"), onClick: () => goTo(index - 1), className: cn(NAV_BUTTON_CLASS, "left-2"), children: _jsx(ChevronLeft, { className: "size-5" }) }), _jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.next"), onClick: () => goTo(index + 1), className: cn(NAV_BUTTON_CLASS, "right-2"), children: _jsx(ChevronRight, { className: "size-5" }) }), showPosition && (_jsx("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-card px-3 py-1 text-xs text-foreground shadow-sm", children: t("kumiko.lightbox.position", { current: index + 1, total }) }))] })), actions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "absolute left-2 top-2 flex items-center gap-2", children: actions }))] }));
|
|
35
35
|
}
|
package/dist/widgets/charts.d.ts
CHANGED
|
@@ -87,6 +87,7 @@ export declare function TimeseriesChart({ points, windowStartMs, windowEndMs, to
|
|
|
87
87
|
readonly testId?: string;
|
|
88
88
|
}): ReactNode;
|
|
89
89
|
export type ChartTone = "positive" | "negative" | "active" | "neutral";
|
|
90
|
+
export declare function chartToneColor(tone: ChartTone): string;
|
|
90
91
|
export type ChartSeries = {
|
|
91
92
|
readonly key: string;
|
|
92
93
|
/** Translated by the caller. */
|
|
@@ -96,6 +97,12 @@ export type ChartSeries = {
|
|
|
96
97
|
export type ChartMarker = {
|
|
97
98
|
readonly atMs: number;
|
|
98
99
|
readonly label: string;
|
|
100
|
+
/** Colors the pin and, on StackedAreaChart, draws a dashed guide line in the plot. */
|
|
101
|
+
readonly color?: string;
|
|
102
|
+
};
|
|
103
|
+
/** Unstacked line drawn over a StackedAreaChart on the same y scale. */
|
|
104
|
+
export type ChartLine = ChartSeries & {
|
|
105
|
+
readonly dashed?: boolean;
|
|
99
106
|
};
|
|
100
107
|
export type SegmentBarRow = {
|
|
101
108
|
readonly key: string;
|
|
@@ -108,13 +115,16 @@ export type SegmentBarRow = {
|
|
|
108
115
|
}[];
|
|
109
116
|
};
|
|
110
117
|
type ToneMap = Readonly<Record<string, ChartTone>>;
|
|
118
|
+
/** Key -> raw CSS color; wins over the tone map. */
|
|
119
|
+
type ColorMap = Readonly<Record<string, string>>;
|
|
111
120
|
/** Vertically stacked bars per bucket (x axis = bucket, not time).
|
|
112
121
|
* The last bucket carries `todayLabel` when it contains `windowEndMs` and
|
|
113
122
|
* has day granularity. */
|
|
114
|
-
export declare function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLabel, formatBucketLabel, formatValue, emptyContent, testId, }: {
|
|
123
|
+
export declare function StackedBarChart({ series, windowEndMs, tones, colors, ariaLabel, todayLabel, formatBucketLabel, formatValue, emptyContent, testId, }: {
|
|
115
124
|
readonly series: readonly ChartSeries[];
|
|
116
125
|
readonly windowEndMs: number;
|
|
117
126
|
readonly tones?: ToneMap;
|
|
127
|
+
readonly colors?: ColorMap;
|
|
118
128
|
readonly ariaLabel: string;
|
|
119
129
|
readonly todayLabel: string;
|
|
120
130
|
readonly formatBucketLabel: (atMs: number) => string;
|
|
@@ -124,22 +134,26 @@ export declare function StackedBarChart({ series, windowEndMs, tones, ariaLabel,
|
|
|
124
134
|
}): ReactNode;
|
|
125
135
|
/** One horizontally stacked bar per row; bar length is relative to the
|
|
126
136
|
* largest row so rows stay comparable. */
|
|
127
|
-
export declare function SegmentBarChart({ rows, tones, ariaLabel, formatValue, testId, }: {
|
|
137
|
+
export declare function SegmentBarChart({ rows, tones, colors, ariaLabel, formatValue, testId, }: {
|
|
128
138
|
readonly rows: readonly SegmentBarRow[];
|
|
129
139
|
readonly tones?: ToneMap;
|
|
140
|
+
readonly colors?: ColorMap;
|
|
130
141
|
readonly ariaLabel: string;
|
|
131
142
|
readonly formatValue?: (value: number) => string;
|
|
132
143
|
readonly testId?: string;
|
|
133
144
|
}): ReactNode;
|
|
134
145
|
/** Stacked bands over time. Right of `todayMs` is the forecast: lighter
|
|
135
146
|
* fill plus a vertical "today" line. */
|
|
136
|
-
export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, emptyContent, testId, }: {
|
|
147
|
+
export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, colors, markers, lines, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, showLegendTotals, emptyContent, testId, }: {
|
|
137
148
|
readonly series: readonly ChartSeries[];
|
|
138
149
|
readonly windowStartMs: number;
|
|
139
150
|
readonly windowEndMs: number;
|
|
140
151
|
readonly todayMs?: number;
|
|
141
152
|
readonly tones?: ToneMap;
|
|
153
|
+
readonly colors?: ColorMap;
|
|
142
154
|
readonly markers?: readonly ChartMarker[];
|
|
155
|
+
/** Drawn over the bands, not stacked. A `null` value breaks the line. */
|
|
156
|
+
readonly lines?: readonly ChartLine[];
|
|
143
157
|
readonly ariaLabel: string;
|
|
144
158
|
readonly todayLabel: string;
|
|
145
159
|
readonly formatBucketLabel: (atMs: number) => string;
|
|
@@ -147,6 +161,7 @@ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, t
|
|
|
147
161
|
readonly formatMarkerTime?: (atMs: number) => string;
|
|
148
162
|
/** Fixed width per bucket; the plot scrolls horizontally, y ticks stay put. */
|
|
149
163
|
readonly scrollable?: boolean;
|
|
164
|
+
readonly showLegendTotals?: boolean;
|
|
150
165
|
readonly emptyContent?: ReactNode;
|
|
151
166
|
readonly testId?: string;
|
|
152
167
|
}): ReactNode;
|
package/dist/widgets/charts.js
CHANGED
|
@@ -130,8 +130,14 @@ const CHART_TONE_VAR = {
|
|
|
130
130
|
active: "var(--color-status-active)",
|
|
131
131
|
neutral: "var(--color-status-neutral)",
|
|
132
132
|
};
|
|
133
|
+
export function chartToneColor(tone) {
|
|
134
|
+
return CHART_TONE_VAR[tone];
|
|
135
|
+
}
|
|
133
136
|
const CHART_FALLBACK_TONES = ["active", "positive", "negative", "neutral"];
|
|
134
|
-
function chartColor(key, index, tones) {
|
|
137
|
+
function chartColor(key, index, tones, colors) {
|
|
138
|
+
const color = colors?.[key];
|
|
139
|
+
if (color !== undefined)
|
|
140
|
+
return color;
|
|
135
141
|
const tone = tones?.[key] ?? CHART_FALLBACK_TONES[index % CHART_FALLBACK_TONES.length] ?? "active";
|
|
136
142
|
return CHART_TONE_VAR[tone];
|
|
137
143
|
}
|
|
@@ -159,6 +165,19 @@ function bucketTimes(series) {
|
|
|
159
165
|
function valueAt(series, atMs) {
|
|
160
166
|
return series.points.find((p) => p.atMs === atMs)?.value ?? 0;
|
|
161
167
|
}
|
|
168
|
+
function linePathSegments(line, xOf, yOf) {
|
|
169
|
+
const commands = [];
|
|
170
|
+
let penDown = false;
|
|
171
|
+
for (const point of [...line.points].sort((a, b) => a.atMs - b.atMs)) {
|
|
172
|
+
if (point.value === null) {
|
|
173
|
+
penDown = false;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
commands.push(`${penDown ? "L" : "M"} ${xOf(point.atMs).toFixed(1)} ${yOf(point.value).toFixed(1)}`);
|
|
177
|
+
penDown = true;
|
|
178
|
+
}
|
|
179
|
+
return commands.join(" ");
|
|
180
|
+
}
|
|
162
181
|
function seriesTotal(series) {
|
|
163
182
|
return series.points.reduce((sum, p) => sum + (p.value ?? 0), 0);
|
|
164
183
|
}
|
|
@@ -166,7 +185,10 @@ function percent(fraction) {
|
|
|
166
185
|
return `${(fraction * 100).toFixed(2)}%`;
|
|
167
186
|
}
|
|
168
187
|
function ChartLegend({ items, formatValue, }) {
|
|
169
|
-
return (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: items.map((item) => (_jsxs("li", { "data-testid": `chart-legend-${item.key}`, className: "flex items-center gap-1.5", children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-[2px]", style: { backgroundColor: item.color } })
|
|
188
|
+
return (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: items.map((item) => (_jsxs("li", { "data-testid": `chart-legend-${item.key}`, className: "flex items-center gap-1.5", children: [item.swatch === undefined || item.swatch === "area" ? (_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-[2px]", style: { backgroundColor: item.color } })) : (_jsx("span", { "aria-hidden": "true", className: "w-3 border-t-2", style: {
|
|
189
|
+
borderColor: item.color,
|
|
190
|
+
borderTopStyle: item.swatch === "dashed-line" ? "dashed" : "solid",
|
|
191
|
+
} })), _jsx("span", { children: item.label }), item.total !== undefined && (_jsx("span", { className: "tabular-nums text-foreground", children: formatValue(item.total) }))] }, item.key))) }));
|
|
170
192
|
}
|
|
171
193
|
function YTicks({ max, formatValue, className, }) {
|
|
172
194
|
return (_jsx("div", { "aria-hidden": "true", className: cn("flex flex-col-reverse justify-between text-right text-[11px] tabular-nums text-muted-foreground", className), children: Array.from({ length: Y_TICK_COUNT }, (_, i) => (_jsx("span", { children: formatValue((max / (Y_TICK_COUNT - 1)) * i) }, i))) }));
|
|
@@ -175,7 +197,7 @@ const BAR_LABEL_TARGET = 8;
|
|
|
175
197
|
/** Vertically stacked bars per bucket (x axis = bucket, not time).
|
|
176
198
|
* The last bucket carries `todayLabel` when it contains `windowEndMs` and
|
|
177
199
|
* has day granularity. */
|
|
178
|
-
export function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, emptyContent, testId, }) {
|
|
200
|
+
export function StackedBarChart({ series, windowEndMs, tones, colors, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, emptyContent, testId, }) {
|
|
179
201
|
const times = bucketTimes(series);
|
|
180
202
|
if (times.length === 0)
|
|
181
203
|
return _jsx("div", { "data-testid": testId, children: emptyContent });
|
|
@@ -195,19 +217,19 @@ export function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLa
|
|
|
195
217
|
return null;
|
|
196
218
|
return (_jsx("div", { "data-testid": `chart-bar-segment-${s.key}`, style: {
|
|
197
219
|
height: percent(value / (totals[i] ?? 1)),
|
|
198
|
-
backgroundColor: chartColor(s.key, si, tones),
|
|
220
|
+
backgroundColor: chartColor(s.key, si, tones, colors),
|
|
199
221
|
} }, s.key));
|
|
200
222
|
}) }) }), _jsx("div", { className: "h-5 pt-1 text-center text-[11px] leading-4 whitespace-nowrap text-muted-foreground", children: (lastIdx - i) % labelEvery === 0 ? label : "" })] }, atMs));
|
|
201
223
|
}) })] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
|
|
202
224
|
key: s.key,
|
|
203
225
|
label: s.label,
|
|
204
|
-
color: chartColor(s.key, i, tones),
|
|
226
|
+
color: chartColor(s.key, i, tones, colors),
|
|
205
227
|
total: seriesTotal(s),
|
|
206
228
|
})) })] }));
|
|
207
229
|
}
|
|
208
230
|
/** One horizontally stacked bar per row; bar length is relative to the
|
|
209
231
|
* largest row so rows stay comparable. */
|
|
210
|
-
export function SegmentBarChart({ rows, tones, ariaLabel, formatValue = String, testId, }) {
|
|
232
|
+
export function SegmentBarChart({ rows, tones, colors, ariaLabel, formatValue = String, testId, }) {
|
|
211
233
|
const rowTotal = (row) => row.segments.length > 0 ? row.segments.reduce((sum, s) => sum + s.value, 0) : row.value;
|
|
212
234
|
const maxTotal = Math.max(1, ...rows.map(rowTotal));
|
|
213
235
|
const segmentTotals = new Map();
|
|
@@ -223,23 +245,35 @@ export function SegmentBarChart({ rows, tones, ariaLabel, formatValue = String,
|
|
|
223
245
|
const legendKeys = [...segmentTotals.keys()];
|
|
224
246
|
return (_jsxs("div", { "data-testid": testId, role: "img", "aria-label": ariaLabel, className: "flex flex-col gap-3", children: [_jsx("ul", { className: "flex flex-col gap-3", children: rows.map((row) => (_jsxs("li", { "data-testid": `chart-row-${row.key}`, className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: formatValue(row.value) })] }), _jsx("div", { className: "flex h-2 gap-px overflow-hidden rounded-full bg-muted", children: row.segments.map((seg) => (_jsx("div", { title: `${seg.label}: ${formatValue(seg.value)}`, className: "h-full", style: {
|
|
225
247
|
width: percent(seg.value / maxTotal),
|
|
226
|
-
backgroundColor: chartColor(seg.key, legendKeys.indexOf(seg.key), tones),
|
|
248
|
+
backgroundColor: chartColor(seg.key, legendKeys.indexOf(seg.key), tones, colors),
|
|
227
249
|
} }, seg.key))) })] }, row.key))) }), _jsx(ChartLegend, { formatValue: formatValue, items: legendKeys.map((key, i) => ({
|
|
228
250
|
key,
|
|
229
251
|
label: segmentTotals.get(key)?.label ?? key,
|
|
230
|
-
color: chartColor(key, i, tones),
|
|
252
|
+
color: chartColor(key, i, tones, colors),
|
|
231
253
|
total: segmentTotals.get(key)?.total ?? 0,
|
|
232
254
|
})) })] }));
|
|
233
255
|
}
|
|
256
|
+
// Two markers may share atMs and label, so the occurrence count keeps React keys unique.
|
|
234
257
|
function sortMarkers(markers) {
|
|
235
|
-
|
|
258
|
+
const seen = new Map();
|
|
259
|
+
return [...markers]
|
|
260
|
+
.sort((a, b) => a.atMs - b.atMs)
|
|
261
|
+
.map((marker) => {
|
|
262
|
+
const base = `${marker.atMs}-${marker.label}`;
|
|
263
|
+
const occurrence = seen.get(base) ?? 0;
|
|
264
|
+
seen.set(base, occurrence + 1);
|
|
265
|
+
return { ...marker, key: `${base}-${occurrence}` };
|
|
266
|
+
});
|
|
236
267
|
}
|
|
237
268
|
function MarkerPins({ markers, windowStartMs, windowEndMs, }) {
|
|
238
269
|
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
239
|
-
return (_jsx("div", { "aria-hidden": "true", className: "relative mt-1 h-5", children: sortMarkers(markers).map((marker, i) => (_jsx("span", { "data-testid": "chart-marker-pin", className: "absolute flex size-4 -translate-x-1/2 items-center justify-center rounded-full
|
|
270
|
+
return (_jsx("div", { "aria-hidden": "true", className: "relative mt-1 h-5", children: sortMarkers(markers).map((marker, i) => (_jsx("span", { "data-testid": "chart-marker-pin", className: cn("absolute flex size-4 -translate-x-1/2 items-center justify-center rounded-full text-[10px] font-medium text-background", marker.color === undefined && "bg-foreground"), style: {
|
|
271
|
+
left: percent(Math.max(0, Math.min(1, (marker.atMs - windowStartMs) / span))),
|
|
272
|
+
...(marker.color !== undefined && { backgroundColor: marker.color }),
|
|
273
|
+
}, children: i + 1 }, marker.key))) }));
|
|
240
274
|
}
|
|
241
275
|
function MarkerLegend({ markers, formatMarkerTime, }) {
|
|
242
|
-
return (_jsx("ol", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: sortMarkers(markers).map((marker, i) => (_jsxs("li", { "data-testid": "chart-marker-item", className: "flex gap-2", children: [_jsx("span", { className: "w-4 shrink-0 text-center tabular-nums text-foreground", children: i + 1 }), _jsx("span", { children: marker.label }), formatMarkerTime !== undefined && (_jsx("span", { className: "ml-auto tabular-nums", children: formatMarkerTime(marker.atMs) }))] },
|
|
276
|
+
return (_jsx("ol", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: sortMarkers(markers).map((marker, i) => (_jsxs("li", { "data-testid": "chart-marker-item", className: "flex gap-2", children: [_jsx("span", { className: "w-4 shrink-0 text-center tabular-nums text-foreground", style: marker.color !== undefined ? { color: marker.color } : undefined, children: i + 1 }), _jsx("span", { children: marker.label }), formatMarkerTime !== undefined && (_jsx("span", { className: "ml-auto tabular-nums", children: formatMarkerTime(marker.atMs) }))] }, marker.key))) }));
|
|
243
277
|
}
|
|
244
278
|
const SCROLLABLE_BUCKET_WIDTH_PX = 48;
|
|
245
279
|
function bucketLabelTransform(index, count) {
|
|
@@ -249,10 +283,10 @@ function bucketLabelTransform(index, count) {
|
|
|
249
283
|
}
|
|
250
284
|
/** Stacked bands over time. Right of `todayMs` is the forecast: lighter
|
|
251
285
|
* fill plus a vertical "today" line. */
|
|
252
|
-
export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, emptyContent, testId, }) {
|
|
286
|
+
export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, colors, markers, lines = [], ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, showLegendTotals = true, emptyContent, testId, }) {
|
|
253
287
|
const clipId = useId();
|
|
254
288
|
const scrollRef = useRef(null);
|
|
255
|
-
const times = bucketTimes(series);
|
|
289
|
+
const times = bucketTimes([...series, ...lines]);
|
|
256
290
|
const bucketCount = times.length;
|
|
257
291
|
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
258
292
|
const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
|
|
@@ -271,9 +305,10 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
|
|
|
271
305
|
const width = 300;
|
|
272
306
|
const height = 120;
|
|
273
307
|
const totals = times.map((atMs) => series.reduce((sum, s) => sum + valueAt(s, atMs), 0));
|
|
308
|
+
const lineValues = lines.flatMap((line) => line.points.flatMap((p) => (p.value === null ? [] : [p.value])));
|
|
274
309
|
const isSingleBucket = times.length === 1;
|
|
275
310
|
// Doubling keeps a lone bucket away from the top edge of the y-scale.
|
|
276
|
-
const max = niceCeil(Math.max(...totals) * (isSingleBucket ? 2 : 1));
|
|
311
|
+
const max = niceCeil(Math.max(...totals, ...lineValues) * (isSingleBucket ? 2 : 1));
|
|
277
312
|
const yOf = (value) => height - (value / max) * height;
|
|
278
313
|
const lower = times.map(() => 0);
|
|
279
314
|
const bands = series.map((s, si) => {
|
|
@@ -288,21 +323,41 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
|
|
|
288
323
|
lower[ti] = upper[ti] ?? 0;
|
|
289
324
|
return {
|
|
290
325
|
key: s.key,
|
|
291
|
-
color: chartColor(s.key, si, tones),
|
|
326
|
+
color: chartColor(s.key, si, tones, colors),
|
|
292
327
|
d: `M ${top.join(" L ")} L ${bottom.join(" L ")} Z`,
|
|
293
328
|
dot: isSingleBucket && bucketHeight > 0 ? clampDotY(yOf(bucketTop), height) : undefined,
|
|
294
329
|
};
|
|
295
330
|
});
|
|
331
|
+
const lineColorOffset = series.length;
|
|
332
|
+
const linePaths = lines.map((line, li) => ({
|
|
333
|
+
key: line.key,
|
|
334
|
+
label: line.label,
|
|
335
|
+
dashed: line.dashed === true,
|
|
336
|
+
color: chartColor(line.key, lineColorOffset + li, tones, colors),
|
|
337
|
+
d: linePathSegments(line, (atMs) => fractionOf(atMs) * width, yOf),
|
|
338
|
+
}));
|
|
339
|
+
const coloredMarkers = sortMarkers(markers ?? []).filter((marker) => marker.color !== undefined);
|
|
296
340
|
const todayX = (todayFraction ?? 1) * width;
|
|
297
341
|
const renderBands = (fillOpacity) => bands.map((band) => band.dot === undefined ? (_jsx("path", { d: band.d, fill: band.color, fillOpacity: fillOpacity, stroke: "none" }, band.key)) : (_jsx("circle", { "data-testid": "stacked-area-single-point", cx: width / 2, cy: band.dot, r: SINGLE_POINT_RADIUS, fill: band.color, fillOpacity: fillOpacity }, band.key)));
|
|
298
|
-
const plot = (_jsxs(_Fragment, { children: [todayFraction !== undefined && (_jsx("span", { "data-testid": "chart-today-label", className: "absolute -top-4 -translate-x-1/2 text-[11px] text-foreground", style: { left: percent(todayFraction) }, children: todayLabel })), _jsxs("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", className: "block h-32 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsxs("defs", { children: [_jsx("clipPath", { id: `${clipId}-past`, children: _jsx("rect", { x: 0, y: 0, width: todayX, height: height }) }), _jsx("clipPath", { id: `${clipId}-future`, children: _jsx("rect", { x: todayX, y: 0, width: width - todayX, height: height }) })] }), _jsx("g", { clipPath: `url(#${clipId}-past)`, children: renderBands(0.85) }), todayFraction !== undefined && (_jsx("g", { "data-testid": "chart-forecast-region", clipPath: `url(#${clipId}-future)`, children: renderBands(0.3) })),
|
|
342
|
+
const plot = (_jsxs(_Fragment, { children: [todayFraction !== undefined && (_jsx("span", { "data-testid": "chart-today-label", className: "absolute -top-4 -translate-x-1/2 text-[11px] text-foreground", style: { left: percent(todayFraction) }, children: todayLabel })), _jsxs("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", className: "block h-32 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsxs("defs", { children: [_jsx("clipPath", { id: `${clipId}-past`, children: _jsx("rect", { x: 0, y: 0, width: todayX, height: height }) }), _jsx("clipPath", { id: `${clipId}-future`, children: _jsx("rect", { x: todayX, y: 0, width: width - todayX, height: height }) })] }), _jsx("g", { clipPath: `url(#${clipId}-past)`, children: renderBands(0.85) }), todayFraction !== undefined && (_jsx("g", { "data-testid": "chart-forecast-region", clipPath: `url(#${clipId}-future)`, children: renderBands(0.3) })), coloredMarkers.map((marker) => {
|
|
343
|
+
const x = fractionOf(marker.atMs) * width;
|
|
344
|
+
return (_jsx("line", { "data-testid": "chart-marker-guide", x1: x, x2: x, y1: 0, y2: height, stroke: marker.color, strokeWidth: 1, strokeDasharray: "2 3", vectorEffect: "non-scaling-stroke" }, marker.key));
|
|
345
|
+
}), linePaths.map((line) => (_jsx("path", { "data-testid": `chart-line-${line.key}`, d: line.d, fill: "none", stroke: line.color, strokeWidth: 2, strokeDasharray: line.dashed ? "6 4" : undefined, vectorEffect: "non-scaling-stroke" }, line.key))), todayFraction !== undefined && (_jsx("line", { "data-testid": "chart-today-line", x1: todayX, x2: todayX, y1: 0, y2: height, stroke: "var(--color-foreground)", strokeWidth: 1, strokeDasharray: "3 3", vectorEffect: "non-scaling-stroke" }))] }), scrollable ? (_jsx("div", { "data-testid": "chart-bucket-labels", className: "relative mt-1 h-4 text-[11px] text-muted-foreground", children: times.map((atMs, i) => (_jsx("span", { className: cn("absolute whitespace-nowrap", bucketLabelTransform(i, times.length)), style: { left: percent(fractionOf(atMs)) }, children: formatBucketLabel(atMs) }, atMs))) })) : (_jsxs("div", { className: "mt-1 flex justify-between text-[11px] text-muted-foreground", children: [_jsx("span", { children: formatBucketLabel(windowStartMs) }), _jsx("span", { children: formatBucketLabel(windowEndMs) })] })), markers !== undefined && markers.length > 0 && (_jsx(MarkerPins, { markers: markers, windowStartMs: windowStartMs, windowEndMs: windowEndMs }))] }));
|
|
299
346
|
return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex gap-2", children: [_jsx(YTicks, { max: max, formatValue: formatValue, className: cn("h-32 pb-0", scrollable && "mt-4") }), scrollable ? (_jsx("div", { ref: scrollRef, "data-testid": "chart-scroll-container", className: "grow overflow-x-auto pt-4", children: _jsx("div", { "data-testid": "chart-scroll-content", className: "relative", style: {
|
|
300
347
|
minWidth: "100%",
|
|
301
348
|
width: `${times.length * SCROLLABLE_BUCKET_WIDTH_PX}px`,
|
|
302
|
-
}, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
349
|
+
}, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items: [
|
|
350
|
+
...series.map((s, i) => ({
|
|
351
|
+
key: s.key,
|
|
352
|
+
label: s.label,
|
|
353
|
+
color: chartColor(s.key, i, tones, colors),
|
|
354
|
+
...(showLegendTotals && { total: seriesTotal(s) }),
|
|
355
|
+
})),
|
|
356
|
+
...linePaths.map((line) => ({
|
|
357
|
+
key: line.key,
|
|
358
|
+
label: line.label,
|
|
359
|
+
color: line.color,
|
|
360
|
+
swatch: line.dashed ? "dashed-line" : "line",
|
|
361
|
+
})),
|
|
362
|
+
] }), markers !== undefined && markers.length > 0 && (_jsx(MarkerLegend, { markers: markers, formatMarkerTime: formatMarkerTime }))] }));
|
|
308
363
|
}
|
|
@@ -4,6 +4,8 @@ export type ProgressListRow = {
|
|
|
4
4
|
readonly label: string;
|
|
5
5
|
readonly value: string;
|
|
6
6
|
readonly fraction: number;
|
|
7
|
+
/** Small line under the bar, e.g. "42 % repaid". */
|
|
8
|
+
readonly sub?: string;
|
|
7
9
|
};
|
|
8
10
|
/** Liste aus Label/Wert-Kopfzeile + Fortschrittsbalken pro Eintrag (z.B.
|
|
9
11
|
* Tilgungsfortschritt pro Kredit). */
|
|
@@ -6,5 +6,5 @@ export function ProgressList({ rows, emptyContent, testId, }) {
|
|
|
6
6
|
if (rows.length === 0) {
|
|
7
7
|
return _jsx("div", { "data-testid": testId, children: emptyContent });
|
|
8
8
|
}
|
|
9
|
-
return (_jsx("ul", { "data-testid": testId, className: "flex max-h-64 flex-col gap-3 overflow-y-auto pr-1", children: rows.map((row) => (_jsxs("li", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: row.value })] }), _jsx(ProgressBar, { value: row.fraction })] }, row.id))) }));
|
|
9
|
+
return (_jsx("ul", { "data-testid": testId, className: "flex max-h-64 flex-col gap-3 overflow-y-auto pr-1", children: rows.map((row) => (_jsxs("li", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: row.value })] }), _jsx(ProgressBar, { value: row.fraction }), row.sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: row.sub })] }, row.id))) }));
|
|
10
10
|
}
|
package/dist/widgets/stat.d.ts
CHANGED
|
@@ -28,11 +28,14 @@ export declare function StatCard({ icon, label, value, sub, tone, accentColor, d
|
|
|
28
28
|
}): ReactNode;
|
|
29
29
|
/** Flat KPI cell for a strip (divider instead of card): label, value with
|
|
30
30
|
* sparkline beside it, sub line. */
|
|
31
|
-
export declare function StatStripCell({ label, value, sub, tone, delta, spark, testId, }: {
|
|
31
|
+
export declare function StatStripCell({ icon, label, value, sub, tone, accentColor, delta, spark, testId, }: {
|
|
32
|
+
/** Chip before the label, colored like StatCard's (`accentColor` or the tone). */
|
|
33
|
+
readonly icon?: ReactNode;
|
|
32
34
|
readonly label: string;
|
|
33
35
|
readonly value: string;
|
|
34
36
|
readonly sub?: string;
|
|
35
37
|
readonly tone?: StatTone;
|
|
38
|
+
readonly accentColor?: string;
|
|
36
39
|
readonly delta?: StatDelta;
|
|
37
40
|
readonly spark?: readonly number[];
|
|
38
41
|
readonly testId?: string;
|
package/dist/widgets/stat.js
CHANGED
|
@@ -32,6 +32,14 @@ export function Sparkline({ points, className, }) {
|
|
|
32
32
|
.join(" ");
|
|
33
33
|
return (_jsxs("svg", { viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: "none", className: className ?? "h-7 w-full", "aria-hidden": "true", children: [_jsx("path", { d: `${line} L${w},${h} L0,${h} Z`, fill: "currentColor", fillOpacity: 0.12 }), _jsx("path", { d: line, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" })] }));
|
|
34
34
|
}
|
|
35
|
+
function StatIconChip({ icon, tone, accentColor, }) {
|
|
36
|
+
return (_jsx("span", { className: cn("flex size-7 shrink-0 items-center justify-center rounded-lg", accentColor === undefined && TONE_CHIP[tone]), style: accentColor === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: {
|
|
39
|
+
color: accentColor,
|
|
40
|
+
backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
|
41
|
+
}, children: icon }));
|
|
42
|
+
}
|
|
35
43
|
/** Icon-tragende Kennzahl-Kachel mit optionalem Delta-Chip, Trend-Zeile
|
|
36
44
|
* und Sparkline. `icon` ist ein fertiger Knoten (App liefert ihr SVG). */
|
|
37
45
|
export function StatCard({ icon, label, value, sub, tone = "default", accentColor, delta, trend, spark, testId, children, }) {
|
|
@@ -40,17 +48,12 @@ export function StatCard({ icon, label, value, sub, tone = "default", accentColo
|
|
|
40
48
|
// a whole-section frame) — an extension section mounting several of
|
|
41
49
|
// these renders a grid of tiles inside its own host Card, not a
|
|
42
50
|
// duplicate outer frame around the section's full content.
|
|
43
|
-
return (_jsxs(Card, { options: { padded: false }, className: "p-4", testId: testId, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 basis-[7rem] items-center gap-2 text-muted-foreground", children: [icon !== undefined && (_jsx("span", { className: cn("flex
|
|
44
|
-
? undefined
|
|
45
|
-
: {
|
|
46
|
-
color: accentColor,
|
|
47
|
-
backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
|
48
|
-
}, children: icon })), _jsx("span", { title: label, className: "min-w-0 line-clamp-2 text-xs font-medium", children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsx("div", { className: "mt-2 text-xl font-semibold tabular-nums text-foreground", children: value }), sub !== undefined && _jsx("div", { className: "mt-0.5 text-xs text-muted-foreground", children: sub }), trend !== undefined && (_jsx("div", { className: "mt-0.5 text-xs font-medium text-foreground/80", children: trend })), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("mt-2 h-7 w-full", TONE_VALUE[tone]) })), children !== undefined && _jsx("div", { className: "mt-3", children: children })] }));
|
|
51
|
+
return (_jsxs(Card, { options: { padded: false }, className: "p-4", testId: testId, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 basis-[7rem] items-center gap-2 text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { title: label, className: "min-w-0 line-clamp-2 text-xs font-medium", children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsx("div", { className: "mt-2 text-xl font-semibold tabular-nums text-foreground", children: value }), sub !== undefined && _jsx("div", { className: "mt-0.5 text-xs text-muted-foreground", children: sub }), trend !== undefined && (_jsx("div", { className: "mt-0.5 text-xs font-medium text-foreground/80", children: trend })), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("mt-2 h-7 w-full", TONE_VALUE[tone]) })), children !== undefined && _jsx("div", { className: "mt-3", children: children })] }));
|
|
49
52
|
}
|
|
50
53
|
/** Flat KPI cell for a strip (divider instead of card): label, value with
|
|
51
54
|
* sparkline beside it, sub line. */
|
|
52
|
-
export function StatStripCell({ label, value, sub, tone = "default", delta, spark, testId, }) {
|
|
53
|
-
return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
55
|
+
export function StatStripCell({ icon, label, value, sub, tone = "default", accentColor, delta, spark, testId, }) {
|
|
56
|
+
return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: cn("text-xl font-semibold tabular-nums", TONE_VALUE[tone]), children: value }), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("h-7 w-20 shrink-0", TONE_VALUE[tone]) }))] }), sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: sub })] }));
|
|
54
57
|
}
|
|
55
58
|
/** Kompakte Kennzahl-Kachel (ohne Icon) — für dichte KPI-Raster.
|
|
56
59
|
* StatCard ist die icon-tragende Variante. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.351.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
26
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
27
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
23
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.351.0",
|
|
24
|
+
"@cosmicdrift/kumiko-framework": "0.351.0",
|
|
25
|
+
"@cosmicdrift/kumiko-headless": "0.351.0",
|
|
26
|
+
"@cosmicdrift/kumiko-renderer": "0.351.0",
|
|
27
|
+
"@cosmicdrift/kumiko-types": "0.351.0",
|
|
28
28
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
29
29
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
30
30
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@types/react-dom": "^19.2.3",
|
|
73
73
|
"jsdom": "^29.1.1",
|
|
74
74
|
"tailwindcss": "^4.3.0",
|
|
75
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
75
|
+
"@cosmicdrift/kumiko-locale-de": "0.351.0"
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.351.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Dashboard panels take visibleWhen; stat-group subtitle and strip icons, progress-list sub line, stacked-area lines, markerKinds, seriesColors, legendTotals and ranges",
|
|
6
|
+
"detail": "Dashboard panel gates, stat-group subtitle, progress sub line, stacked-area lines, marker kinds and ranges\n`stat`, `stat-group`, `chart`, `list`, `feed` and `progress-list` panels take `visibleWhen` (`DashboardPanelGate`), like `screen` panels. The gate query gets the screen filter and time range. The panel renders nothing while the gate loads or is unmet, and shows an error with retry when the gate query fails. Panels with the same gate query and payload share one live request. The boot validator checks the gate query and field and rejects `visibleWhen` on stat-group children.\nA labeled `stat-group` takes a `subtitle`. In an unlabeled group (KPI strip) each child keeps its `icon` and `accentColor`. `progress-list` rows take an optional `sub` (`DashboardText`) shown under the bar.\n`stacked-area` results can carry `lines` (`{ key, label, points, dashed? }`, drawn unstacked over the bands) and `markers[].kind`. New chart options: `seriesColors` (key to CSS color, wins over `seriesTones`), and only for stacked-area `markerKinds` (kind to `{ tone }` or `{ color }`: colored pin plus dashed guide line), `legendTotals: false` and `ranges` (a range switch in the panel header that windows bands, lines and markers by `months`). `StackedAreaChart` gets `lines`, `colors` and `showLegendTotals`; `ChartMarker` gets `color`."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.351.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Tabbed projectionDetail panels taller than the viewport scroll again"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.350.0",
|
|
15
|
+
"type": "improvement",
|
|
16
|
+
"title": "Row actions with display \"responsive\", Lightbox actions and showPosition, navigate with searchParams, facet counts refresh after writes",
|
|
17
|
+
"detail": "Responsive row actions, Lightbox actions, navigate with search params, fresh facet counts\n`RowActionDisplay` gets `\"responsive\"`: a labelled button with icon in the table (768px and up), icon only in the narrow card layout with the label as accessible name. Without an icon it stays a button. Existing values keep their look. Below 768px, icon-only row actions and the row expand arrow have a 44px touch target.\n`Lightbox` takes `actions` (nodes in the top-left corner, e.g. a download button) and `showPosition` (default `true`; `false` hides the `{current} / {total}` counter).\n`NavApi.navigate`, `replace` and `hrefFor` take an optional `{ searchParams }` (`NavigateOptions`). Without it the query is dropped as before. `listFilterUrlKey(screenId, field)` builds the URL key a list reads a facet filter from.\nFacet chip counts refetch after a write from a row action, toolbar action, drawer or expanded row, and follow the entity's live events. Before, they kept the count from the first load."
|
|
18
|
+
},
|
|
2
19
|
{
|
|
3
20
|
"version": "0.349.0",
|
|
4
21
|
"type": "improvement",
|