@cosmicdrift/kumiko-renderer-web 0.350.0 → 0.352.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 +136 -43
- package/dist/layout/nav-tree.js +14 -1
- package/dist/primitives/combobox.d.ts +2 -0
- package/dist/primitives/combobox.js +2 -2
- 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 +30 -6
- 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 +28 -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)
|
|
@@ -224,18 +225,51 @@ function readDelta(panel, record) {
|
|
|
224
225
|
const tone = readTone(panel.deltaToneField !== undefined ? record[panel.deltaToneField] : undefined);
|
|
225
226
|
return { value: String(value), direction, ...(tone !== undefined && { tone }) };
|
|
226
227
|
}
|
|
228
|
+
function labeledGroupColumns(valueCount) {
|
|
229
|
+
if (valueCount <= 1)
|
|
230
|
+
return "";
|
|
231
|
+
return valueCount === 2 ? "sm:grid-cols-2" : "sm:grid-cols-3";
|
|
232
|
+
}
|
|
227
233
|
function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
228
234
|
const testId = `dashboard-panel-${panel.id}`;
|
|
229
235
|
if (label === undefined) {
|
|
230
236
|
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
237
|
}
|
|
232
|
-
return (_jsx(SectionCard, { title: label, testId: testId, children: _jsx("section", { className:
|
|
238
|
+
return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: `grid grid-cols-1 gap-3 ${labeledGroupColumns(panel.stats.length)}`, children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
239
|
+
}
|
|
240
|
+
function markerKindColor(kind) {
|
|
241
|
+
if (kind === undefined)
|
|
242
|
+
return undefined;
|
|
243
|
+
if (kind.color !== undefined)
|
|
244
|
+
return kind.color;
|
|
245
|
+
return kind.tone !== undefined ? chartToneColor(kind.tone) : undefined;
|
|
246
|
+
}
|
|
247
|
+
function resolveMarkers(markers, translate, formats, markerKinds) {
|
|
248
|
+
return markers?.map((marker) => {
|
|
249
|
+
const color = typeof marker.kind === "string" ? markerKindColor(markerKinds?.[marker.kind]) : undefined;
|
|
250
|
+
return {
|
|
251
|
+
atMs: marker.atMs,
|
|
252
|
+
label: resolveDashboardText(marker.label, translate, formats) ?? "",
|
|
253
|
+
...(color !== undefined && { color }),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
233
256
|
}
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
257
|
+
function addUtcMonths(atMs, months) {
|
|
258
|
+
const date = new Date(atMs);
|
|
259
|
+
date.setUTCMonth(date.getUTCMonth() + months);
|
|
260
|
+
return date.getTime();
|
|
261
|
+
}
|
|
262
|
+
// Same window as a "1/3/5 years" switch over a plan: `months` ahead of today,
|
|
263
|
+
// pulled back when the data ends earlier, never before the first bucket.
|
|
264
|
+
function rangeWindow(months, anchorMs, firstMs, lastMs) {
|
|
265
|
+
const endMs = Math.min(lastMs, addUtcMonths(anchorMs, months));
|
|
266
|
+
return { startMs: Math.max(firstMs, addUtcMonths(endMs, -months)), endMs };
|
|
267
|
+
}
|
|
268
|
+
function pointsWithin(points, window) {
|
|
269
|
+
return points.filter((p) => p.atMs >= window.startMs && p.atMs <= window.endMs);
|
|
270
|
+
}
|
|
271
|
+
function selectedRangeMonths(ranges, selected) {
|
|
272
|
+
return ranges?.options.find((option) => option.value === selected)?.months;
|
|
239
273
|
}
|
|
240
274
|
// A plain bar chart ("changes per day") ships `points` without `series`.
|
|
241
275
|
const SINGLE_SERIES_KEY = "value";
|
|
@@ -272,18 +306,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
272
306
|
const t = useTranslation();
|
|
273
307
|
const todayLabel = t("kumiko.dashboard.today");
|
|
274
308
|
const tones = panel.seriesTones;
|
|
309
|
+
const colors = panel.seriesColors;
|
|
275
310
|
const testId = `dashboard-chart-${panel.id}`;
|
|
276
|
-
|
|
311
|
+
const ranges = panel.chart === "stacked-area" ? panel.ranges : undefined;
|
|
312
|
+
const [selectedRange, setSelectedRange] = useState(ranges?.default);
|
|
313
|
+
const rangeSwitch = ranges !== undefined && selectedRange !== undefined ? (_jsx(ModeSwitch, { value: selectedRange, options: ranges.options.map((option) => ({
|
|
314
|
+
value: option.value,
|
|
315
|
+
label: translate(option.label),
|
|
316
|
+
})), onChange: setSelectedRange, ariaLabel: label, variant: "pill", testId: `dashboard-chart-range-${panel.id}` })) : undefined;
|
|
317
|
+
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
318
|
const series = chartSeries(data, label, translate);
|
|
278
319
|
const { startMs, endMs } = windowOf(data, series);
|
|
279
|
-
const markers = resolveMarkers(data.markers, translate, formats);
|
|
320
|
+
const markers = resolveMarkers(data.markers, translate, formats, panel.markerKinds);
|
|
280
321
|
const formatValue = formats.formatPanelValue(panel.valueFormat);
|
|
281
322
|
if (panel.chart === "segment-bars") {
|
|
282
323
|
return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
|
|
283
324
|
...row,
|
|
284
325
|
label: translate(row.label),
|
|
285
326
|
segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
|
|
286
|
-
})), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
327
|
+
})), tones: tones, colors: colors, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
287
328
|
}
|
|
288
329
|
if (panel.chart === "stacked-bars") {
|
|
289
330
|
const times = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
@@ -293,11 +334,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
293
334
|
const prev = all[i - 1];
|
|
294
335
|
return prev === undefined ? [] : [atMs - prev];
|
|
295
336
|
}), 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 }));
|
|
337
|
+
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
338
|
}
|
|
298
339
|
if (panel.chart === "stacked-area") {
|
|
299
|
-
|
|
300
|
-
|
|
340
|
+
const lines = (data.lines ?? []).map((line) => ({
|
|
341
|
+
...line,
|
|
342
|
+
label: translate(line.label),
|
|
343
|
+
}));
|
|
344
|
+
const todayMs = data.todayMs ?? undefined;
|
|
345
|
+
const months = selectedRangeMonths(ranges, selectedRange);
|
|
346
|
+
const bucketTimes = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
347
|
+
const window = months === undefined || bucketTimes.length === 0
|
|
348
|
+
? { startMs, endMs }
|
|
349
|
+
: rangeWindow(months, todayMs ?? Math.max(...bucketTimes), Math.min(...bucketTimes), Math.max(...bucketTimes));
|
|
350
|
+
const isWindowed = months !== undefined;
|
|
351
|
+
return (_jsx(StackedAreaChart, { series: isWindowed
|
|
352
|
+
? series.map((s) => ({ ...s, points: pointsWithin(s.points, window) }))
|
|
353
|
+
: series, lines: isWindowed
|
|
354
|
+
? lines.map((line) => ({ ...line, points: pointsWithin(line.points, window) }))
|
|
355
|
+
: 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
356
|
}
|
|
302
357
|
return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
|
|
303
358
|
yAxis: { ticks: 3, format: formatValue },
|
|
@@ -336,12 +391,16 @@ function FeedPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
336
391
|
function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
|
|
337
392
|
const formats = useDashboardFormats();
|
|
338
393
|
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
|
-
|
|
394
|
+
const rows = (data.rows ?? []).map((row, i) => {
|
|
395
|
+
const sub = resolveDashboardText(row.sub, translate, formats);
|
|
396
|
+
return {
|
|
397
|
+
id: String(i),
|
|
398
|
+
label: resolveDashboardText(row.label, translate, formats) ?? "—",
|
|
399
|
+
value: resolveDashboardText(row.value, translate, formats) ?? "—",
|
|
400
|
+
fraction: row.fraction,
|
|
401
|
+
...(sub !== undefined && { sub }),
|
|
402
|
+
};
|
|
403
|
+
});
|
|
345
404
|
return _jsx(ProgressList, { rows: rows });
|
|
346
405
|
} }));
|
|
347
406
|
}
|
|
@@ -439,33 +498,67 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
|
439
498
|
return (_jsx(ProgressListPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
|
|
440
499
|
}
|
|
441
500
|
const VisibilityQueryContext = createContext(new Map());
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
501
|
+
const NO_GATE_PAYLOAD = {};
|
|
502
|
+
function visibilityGate(query, payload) {
|
|
503
|
+
return { key: `${query}|${JSON.stringify(payload)}`, query, payload };
|
|
504
|
+
}
|
|
505
|
+
// Embedded screens carry no screen filter; in-grid panels pass the filter
|
|
506
|
+
// (unless ignoreScreenFilter, never for a stat-group) and the time range.
|
|
507
|
+
function panelVisibilityGate(panel, screenParams) {
|
|
508
|
+
if (panel.kind === "custom" || panel.visibleWhen === undefined)
|
|
509
|
+
return undefined;
|
|
510
|
+
if (panel.kind === "screen")
|
|
511
|
+
return visibilityGate(panel.visibleWhen.query, NO_GATE_PAYLOAD);
|
|
512
|
+
const dropsFilter = panel.kind !== "stat-group" && panel.ignoreScreenFilter === true;
|
|
513
|
+
return visibilityGate(panel.visibleWhen.query, {
|
|
514
|
+
...(dropsFilter ? {} : screenParams.filterParams),
|
|
515
|
+
...screenParams.rangeParams,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
// One live query per distinct gate (query + payload), shared by every panel
|
|
519
|
+
// that gates on it — N panels on the same status query would otherwise each
|
|
520
|
+
// open their own request, SSE subscription and refetch per event.
|
|
521
|
+
function VisibilityQueryScope({ gate, children, }) {
|
|
446
522
|
const parent = useContext(VisibilityQueryContext);
|
|
447
|
-
const result = useQuery(query,
|
|
448
|
-
|
|
523
|
+
const result = useQuery(gate.query, gate.payload, {
|
|
524
|
+
live: true,
|
|
525
|
+
});
|
|
526
|
+
const value = useMemo(() => new Map(parent).set(gate.key, result), [parent, gate.key, result]);
|
|
449
527
|
return (_jsx(VisibilityQueryContext.Provider, { value: value, children: children }));
|
|
450
528
|
}
|
|
451
|
-
function VisibilityQueryScopes({
|
|
452
|
-
const [first, ...rest] =
|
|
529
|
+
function VisibilityQueryScopes({ gates, children, }) {
|
|
530
|
+
const [first, ...rest] = gates;
|
|
453
531
|
if (first === undefined)
|
|
454
532
|
return children;
|
|
455
|
-
return (_jsx(VisibilityQueryScope, {
|
|
533
|
+
return (_jsx(VisibilityQueryScope, { gate: first, children: _jsx(VisibilityQueryScopes, { gates: rest, children: children }) }));
|
|
456
534
|
}
|
|
457
|
-
function
|
|
458
|
-
const
|
|
535
|
+
function distinctVisibilityGates(panels, screenParams) {
|
|
536
|
+
const gates = new Map();
|
|
459
537
|
for (const panel of panels) {
|
|
460
|
-
|
|
461
|
-
|
|
538
|
+
const gate = panelVisibilityGate(panel, screenParams);
|
|
539
|
+
if (gate !== undefined)
|
|
540
|
+
gates.set(gate.key, gate);
|
|
541
|
+
}
|
|
542
|
+
return [...gates.values()];
|
|
543
|
+
}
|
|
544
|
+
function GatedGridCell({ panel, screenParams, label, className, children, }) {
|
|
545
|
+
const gate = panelVisibilityGate(panel, screenParams);
|
|
546
|
+
const visibility = useContext(VisibilityQueryContext).get(gate?.key ?? "");
|
|
547
|
+
const visibleWhen = panel.kind === "custom" ? undefined : panel.visibleWhen;
|
|
548
|
+
if (visibleWhen !== undefined) {
|
|
549
|
+
const verdict = evalVisibleWhen(visibleWhen, visibility);
|
|
550
|
+
if (verdict === "error" && visibility?.error) {
|
|
551
|
+
return (_jsx("div", { className: className, "data-testid": `dashboard-panel-${panel.id}`, children: _jsx(PanelError, { label: label, error: visibility.error, onRetry: () => void visibility.refetch() }) }));
|
|
462
552
|
}
|
|
553
|
+
// Loading renders nothing too: a skeleton that then vanishes flickers.
|
|
554
|
+
if (verdict !== "visible")
|
|
555
|
+
return null;
|
|
463
556
|
}
|
|
464
|
-
return
|
|
557
|
+
return _jsx("div", { className: className, children: children });
|
|
465
558
|
}
|
|
466
559
|
function ScreenPanelTile({ panel, featureName, translate, }) {
|
|
467
560
|
const visibleWhen = panel.visibleWhen;
|
|
468
|
-
const visibility = useContext(VisibilityQueryContext).get(visibleWhen
|
|
561
|
+
const visibility = useContext(VisibilityQueryContext).get(visibleWhen !== undefined ? visibilityGate(visibleWhen.query, NO_GATE_PAYLOAD).key : "");
|
|
469
562
|
const target = useEmbeddedScreen(featureName, panel.screen);
|
|
470
563
|
if (target === undefined)
|
|
471
564
|
return null;
|
|
@@ -502,7 +595,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
502
595
|
const { params: rangeParams, control: rangeControl } = useTimeRange(screen, effectiveTranslate);
|
|
503
596
|
const screenParams = { filterParams, rangeParams };
|
|
504
597
|
const loadedAtMs = useLoadedAt(screenParams);
|
|
505
|
-
const
|
|
598
|
+
const visibilityGates = distinctVisibilityGates(screen.panels, screenParams);
|
|
506
599
|
const scope = screen.scope;
|
|
507
600
|
// A raw description is agent-facing prose; only an i18n key is user-facing copy.
|
|
508
601
|
const translatedDescription = screen.description !== undefined ? effectiveTranslate(screen.description) : undefined;
|
|
@@ -510,13 +603,13 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
510
603
|
const scopeBadge = scope !== undefined ? (_jsx(StatusBadge, { tone: "accent", testId: `dashboard-${screen.id}-scope`, children: effectiveTranslate(scope.badge) })) : null;
|
|
511
604
|
// The shell header owns the title; the badge sits right after it there.
|
|
512
605
|
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, {
|
|
606
|
+
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
607
|
if (panel.kind === "screen") {
|
|
515
608
|
return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
|
|
516
609
|
}
|
|
517
610
|
const label = panel.kind === "custom" || panel.label === undefined
|
|
518
611
|
? undefined
|
|
519
612
|
: effectiveTranslate(panel.label);
|
|
520
|
-
return (_jsx(
|
|
613
|
+
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
614
|
}) }) })] }));
|
|
522
615
|
}
|
package/dist/layout/nav-tree.js
CHANGED
|
@@ -468,11 +468,12 @@ export function buildNavRegistrySliceForApp(app, allowedNavQns) {
|
|
|
468
468
|
});
|
|
469
469
|
}
|
|
470
470
|
}
|
|
471
|
+
const existing = dropNavsWithNonexistentParent(qualified);
|
|
471
472
|
// Workspace filter: drop nav entries whose qualified id isn't in the
|
|
472
473
|
// allow-set. A child whose parent gets dropped surfaces as a top-level
|
|
473
474
|
// entry — the workspace owner should list parents explicitly if they
|
|
474
475
|
// want the grouping preserved.
|
|
475
|
-
const filtered = allowedNavQns !== undefined ?
|
|
476
|
+
const filtered = allowedNavQns !== undefined ? existing.filter((n) => allowedNavQns.has(n.id)) : existing;
|
|
476
477
|
const allowedQnSet = new Set(filtered.map((n) => n.id));
|
|
477
478
|
const topLevel = [];
|
|
478
479
|
const byParentMap = new Map();
|
|
@@ -495,6 +496,18 @@ export function buildNavRegistrySliceForApp(app, allowedNavQns) {
|
|
|
495
496
|
byParent: (parent) => byParentMap.get(parent) ?? [],
|
|
496
497
|
};
|
|
497
498
|
}
|
|
499
|
+
// A parent absent from the (role-projected) schema is not a workspace
|
|
500
|
+
// filter result: the child must not leak to the top level. Transitive.
|
|
501
|
+
function dropNavsWithNonexistentParent(navs) {
|
|
502
|
+
let current = [...navs];
|
|
503
|
+
for (;;) {
|
|
504
|
+
const ids = new Set(current.map((n) => n.id));
|
|
505
|
+
const kept = current.filter((n) => n.parent === undefined || ids.has(n.parent));
|
|
506
|
+
if (kept.length === current.length)
|
|
507
|
+
return current;
|
|
508
|
+
current = kept;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
498
511
|
// Exported (not part of the package's public index.ts surface) so other
|
|
499
512
|
// modules under layout/ — e.g. filter-app-schema-navs.ts — qualify nav ids
|
|
500
513
|
// the same way instead of keeping a second copy in sync.
|
|
@@ -6,6 +6,8 @@ export type ComboboxOption = {
|
|
|
6
6
|
readonly description?: string;
|
|
7
7
|
/** Group heading; see `groupOptions` for the ordering rule. */
|
|
8
8
|
readonly group?: string;
|
|
9
|
+
/** Visible but not choosable. */
|
|
10
|
+
readonly disabled?: boolean;
|
|
9
11
|
};
|
|
10
12
|
type ComboboxBaseProps = {
|
|
11
13
|
readonly id: string;
|
|
@@ -105,7 +105,7 @@ export function ComboboxInput(props) {
|
|
|
105
105
|
const isSelected = multiple
|
|
106
106
|
? selectedValues.has(opt.value)
|
|
107
107
|
: opt.value === singleValue;
|
|
108
|
-
return (_jsxs(Command.Item, { "data-testid": `combobox-${id}-option-${opt.value}`, value: opt.label, keywords: opt.description !== undefined ? [opt.description] : undefined, onSelect: () => {
|
|
108
|
+
return (_jsxs(Command.Item, { "data-testid": `combobox-${id}-option-${opt.value}`, value: opt.label, keywords: opt.description !== undefined ? [opt.description] : undefined, disabled: opt.disabled, onSelect: () => {
|
|
109
109
|
if (props.multiple === true) {
|
|
110
110
|
toggleMulti(opt.value);
|
|
111
111
|
}
|
|
@@ -117,7 +117,7 @@ export function ComboboxInput(props) {
|
|
|
117
117
|
// No pointer-events toggles on the item: with Tailwind active,
|
|
118
118
|
// `data-[disabled]:pointer-events-none` silently swallowed mouse
|
|
119
119
|
// clicks (keyboard select still worked, jsdom tests stayed green).
|
|
120
|
-
className: "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground", children: [isSelected && (_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Check, { className: "h-4 w-4" }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { children: opt.label }), opt.description !== undefined && (_jsx("span", { className: "text-xs text-muted-foreground", children: opt.description }))] })] }, opt.value));
|
|
120
|
+
className: cn("relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground", opt.disabled === true && "cursor-not-allowed opacity-50"), children: [isSelected && (_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Check, { className: "h-4 w-4" }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { children: opt.label }), opt.description !== undefined && (_jsx("span", { className: "text-xs text-muted-foreground", children: opt.description }))] })] }, opt.value));
|
|
121
121
|
});
|
|
122
122
|
return section.heading === undefined ? (items) : (_jsx(Command.Group, { heading: section.heading, className: "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", children: items }, section.heading));
|
|
123
123
|
})] }), onCreate !== undefined && (_jsxs("button", { type: "button", "data-testid": `combobox-${id}-create`, onClick: () => {
|
|
@@ -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
|
@@ -257,21 +257,30 @@ function SegmentedSelect({ id, name, value, onChange, options, disabled, require
|
|
|
257
257
|
onChange(target.value);
|
|
258
258
|
buttonRefs.current[index]?.focus();
|
|
259
259
|
};
|
|
260
|
+
// Arrow keys skip disabled segments; with none enabled they stay put.
|
|
261
|
+
const nextEnabledIndex = (from, step) => {
|
|
262
|
+
for (let hop = 1; hop <= options.length; hop++) {
|
|
263
|
+
const candidate = (from + step * hop + options.length * hop) % options.length;
|
|
264
|
+
if (options[candidate]?.disabled !== true)
|
|
265
|
+
return candidate;
|
|
266
|
+
}
|
|
267
|
+
return from;
|
|
268
|
+
};
|
|
260
269
|
const handleKeyDown = (e, index) => {
|
|
261
270
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
262
271
|
e.preventDefault();
|
|
263
|
-
selectAt((index
|
|
272
|
+
selectAt(nextEnabledIndex(index, 1));
|
|
264
273
|
}
|
|
265
274
|
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
266
275
|
e.preventDefault();
|
|
267
|
-
selectAt((index -
|
|
276
|
+
selectAt(nextEnabledIndex(index, -1));
|
|
268
277
|
}
|
|
269
278
|
};
|
|
270
279
|
return (_jsxs("div", { role: "radiogroup", "aria-labelledby": fieldLabelId(id), "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `segmented-${id}`, className: cn("inline-flex min-h-9 w-fit flex-wrap overflow-hidden rounded-md border", hasError === true ? "border-destructive" : "border-input"), children: [_jsx("input", { type: "hidden", name: name, value: value }), options.map((opt, index) => {
|
|
271
280
|
const checked = opt.value === value;
|
|
272
281
|
return (_jsx("button", { ref: (node) => {
|
|
273
282
|
buttonRefs.current[index] = node;
|
|
274
|
-
}, type: "button", role: "radio", "aria-checked": checked, tabIndex: index === tabStopIndex ? 0 : -1, disabled: disabled, "data-testid": `segmented-${id}-${opt.value}`, onClick: () => onChange(opt.value), onKeyDown: (e) => handleKeyDown(e, index), className: cn(
|
|
283
|
+
}, type: "button", role: "radio", "aria-checked": checked, tabIndex: index === tabStopIndex ? 0 : -1, disabled: disabled === true || opt.disabled === true, "data-testid": `segmented-${id}-${opt.value}`, onClick: () => onChange(opt.value), onKeyDown: (e) => handleKeyDown(e, index), className: cn(
|
|
275
284
|
// Per-segment top/left border instead of the container's
|
|
276
285
|
// `divide-x`: divide-x only draws verticals between siblings
|
|
277
286
|
// in source order, which misplaces borders the moment a
|
|
@@ -302,7 +311,7 @@ function RadioListSelect({ id, name, value, onChange, options, variant = "list",
|
|
|
302
311
|
const descriptionIdOf = (opt) => `${id}-option-${options.findIndex((o) => o.value === opt.value)}-description`;
|
|
303
312
|
return (_jsx("div", { role: "radiogroup", "aria-labelledby": fieldLabelId(id), "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `radio-list-${id}`, "data-radio-list": "", className: "flex flex-col gap-2", children: groupOptions(options).map((section) => (_jsxs(Fragment, { children: [section.heading !== undefined && (_jsx("div", { "data-testid": `radio-list-${id}-group-${section.heading}`, className: "mt-1 text-xs font-medium text-muted-foreground", children: section.heading })), section.options.map((opt) => (_jsxs("label", { className: card
|
|
304
313
|
? RADIO_CARD_CLASS
|
|
305
|
-
: "flex items-start gap-2 text-sm font-normal text-foreground", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
|
|
314
|
+
: "flex items-start gap-2 text-sm font-normal text-foreground has-[:disabled]:opacity-50", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled === true || opt.disabled === true, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
|
|
306
315
|
}
|
|
307
316
|
// The row count reaches the textarea through an untyped view-model hint, so a
|
|
308
317
|
// sloppy schema can hand over a fraction or a zero — neither renders a sane
|
|
@@ -318,6 +327,16 @@ function normalizedTextareaRows(rows) {
|
|
|
318
327
|
function textareaMinHeight(rows) {
|
|
319
328
|
return { minHeight: `calc(${rows} * 1lh + 1rem + 2px)` };
|
|
320
329
|
}
|
|
330
|
+
function withInlineDisabledHint(option) {
|
|
331
|
+
if (option.disabled !== true || option.description === undefined)
|
|
332
|
+
return option;
|
|
333
|
+
return {
|
|
334
|
+
value: option.value,
|
|
335
|
+
label: `${option.label} (${option.description})`,
|
|
336
|
+
disabled: true,
|
|
337
|
+
...(option.group !== undefined && { group: option.group }),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
321
340
|
function DefaultInput(props) {
|
|
322
341
|
// Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
|
|
323
342
|
// selbst — kein manuelles border-destructive mehr nötig.
|
|
@@ -365,6 +384,8 @@ function DefaultInput(props) {
|
|
|
365
384
|
// 4-Item-Status-Selects vorhanden, das ist eine bewusst akzeptierte
|
|
366
385
|
// UX-Konsequenz für Style-Konsistenz.
|
|
367
386
|
const comboOptions = props.options.map((o) => typeof o === "string" ? { value: o, label: o } : o);
|
|
387
|
+
// The dropdown shows a disabled option's hint inline, so it stays readable in the trigger and search.
|
|
388
|
+
const dropdownOptions = comboOptions.map(withInlineDisabledHint);
|
|
368
389
|
// The radio group can't render the unselected placeholder as its own
|
|
369
390
|
// segment, so it counts and renders on the real options only — the
|
|
370
391
|
// dropdown below keeps comboOptions (placeholder included) since it
|
|
@@ -392,7 +413,7 @@ function DefaultInput(props) {
|
|
|
392
413
|
if (presentation === "segmented" && radioGroupOptions.length > 0) {
|
|
393
414
|
return (_jsx(SegmentedSelect, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: radioGroupOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
|
|
394
415
|
}
|
|
395
|
-
return (_jsx(ComboboxInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options:
|
|
416
|
+
return (_jsx(ComboboxInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: dropdownOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
|
|
396
417
|
}
|
|
397
418
|
case "combobox": {
|
|
398
419
|
// Tier 2.1c + Tier 2.7e: Discriminated-Union per `multiple` —
|
|
@@ -1828,7 +1849,10 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
|
|
|
1828
1849
|
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;
|
|
1829
1850
|
const header = s.header ?? defaultHeader;
|
|
1830
1851
|
const hasHeader = header !== null && header !== undefined;
|
|
1831
|
-
const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
|
|
1852
|
+
const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
|
|
1853
|
+
// An unframed card has no radius to clip; overflow-hidden would also
|
|
1854
|
+
// zero its flex-item min-height and cut off content inside a scroller.
|
|
1855
|
+
(framed || fillHeight) && "overflow-hidden",
|
|
1832
1856
|
// Same "no flex-1" reasoning as DefaultForm's own fillHeight card
|
|
1833
1857
|
// (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
|
|
1834
1858
|
// an ancestor is itself height-constrained.
|
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.352.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.352.0",
|
|
24
|
+
"@cosmicdrift/kumiko-framework": "0.352.0",
|
|
25
|
+
"@cosmicdrift/kumiko-headless": "0.352.0",
|
|
26
|
+
"@cosmicdrift/kumiko-renderer": "0.352.0",
|
|
27
|
+
"@cosmicdrift/kumiko-types": "0.352.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.352.0"
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Stat group span and column count",
|
|
6
|
+
"detail": "Set span: \"half\" on a stat-group to place two groups side by side."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.352.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "The sidebar drops navs whose parent is missing from the role-projected schema instead of promoting them to the top level",
|
|
12
|
+
"detail": "Navs whose parent does not exist in the app schema are dropped, transitively, instead of being promoted to the top level. A role-projected schema no longer shows an adopted child nav (e.g. AI providers for a TenantAdmin) when its parent section is system-admin only. Children whose parent exists but is filtered out by a workspace allowlist still surface at the top level."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.352.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Disabled select options are muted and not choosable in dropdown, radio list, radio cards and segmented control"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.351.0",
|
|
21
|
+
"type": "improvement",
|
|
22
|
+
"title": "Dashboard panels take visibleWhen; stat-group subtitle and strip icons, progress-list sub line, stacked-area lines, markerKinds, seriesColors, legendTotals and ranges",
|
|
23
|
+
"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`."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"version": "0.351.0",
|
|
27
|
+
"type": "fix",
|
|
28
|
+
"title": "Tabbed projectionDetail panels taller than the viewport scroll again"
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "0.350.0",
|
|
4
32
|
"type": "improvement",
|