@cosmicdrift/kumiko-renderer-web 0.348.1 → 0.350.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.
@@ -4,6 +4,7 @@ import { dispatcherErrorText, EmbeddedScreenProvider, evalVisibleWhen, extension
4
4
  import { createContext, useContext, useEffect, useMemo, useState } from "react";
5
5
  import { EmbeddedFormProvider } from "../primitives/index.js";
6
6
  import { PageSection } from "../primitives/layout.js";
7
+ import { formatMoney } from "../primitives/money-input.js";
7
8
  import { Skeleton } from "../ui/skeleton.js";
8
9
  import { SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
9
10
  import { DashboardListTable } from "../widgets/dashboard-list.js";
@@ -41,7 +42,14 @@ function useDashboardFormats() {
41
42
  timeStyle: "short",
42
43
  timeZone,
43
44
  });
45
+ const medium = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone });
46
+ const formatMinor = (amountMinor, currency, fractionDigits) => formatMoney(amountMinor, currency, locale, fractionDigits);
44
47
  return {
48
+ formatMedium: (atMs) => medium.format(atMs),
49
+ formatMinor,
50
+ formatPanelValue: (valueFormat) => valueFormat === undefined
51
+ ? (value) => number.format(value)
52
+ : (value) => formatMinor(value, valueFormat.currency, valueFormat.fractionDigits),
45
53
  formatNumber: (value) => number.format(value),
46
54
  formatPercent: (fraction) => percent.format(fraction),
47
55
  formatDay: (atMs) => day.format(atMs),
@@ -49,6 +57,65 @@ function useDashboardFormats() {
49
57
  formatDateTime: (atMs) => dateTime.format(atMs),
50
58
  };
51
59
  }
60
+ const MAX_TEXT_PARAM_DEPTH = 4;
61
+ function isRecord(value) {
62
+ return typeof value === "object" && value !== null && !Array.isArray(value);
63
+ }
64
+ function isDashboardI18nText(value) {
65
+ return isRecord(value) && typeof value["i18nKey"] === "string";
66
+ }
67
+ function isMoneyParam(value) {
68
+ return (isRecord(value) &&
69
+ value["kind"] === "money" &&
70
+ typeof value["amountMinor"] === "number" &&
71
+ typeof value["currency"] === "string");
72
+ }
73
+ function isDateParam(value) {
74
+ return isRecord(value) && value["kind"] === "date" && typeof value["atMs"] === "number";
75
+ }
76
+ function isTextParam(value) {
77
+ return (typeof value === "string" ||
78
+ typeof value === "number" ||
79
+ isDashboardI18nText(value) ||
80
+ isMoneyParam(value) ||
81
+ isDateParam(value));
82
+ }
83
+ function resolveTextParam(param, translate, formats, depth) {
84
+ if (typeof param === "string" || typeof param === "number")
85
+ return param;
86
+ if (isMoneyParam(param))
87
+ return formats.formatMinor(param.amountMinor, param.currency);
88
+ if (isDateParam(param))
89
+ return formats.formatMedium(param.atMs);
90
+ if (isDashboardI18nText(param)) {
91
+ // Too deep: show the key instead of recursing further.
92
+ return depth >= MAX_TEXT_PARAM_DEPTH
93
+ ? param.i18nKey
94
+ : resolveI18nText(param, translate, formats, depth + 1);
95
+ }
96
+ return "";
97
+ }
98
+ function resolveI18nText(text, translate, formats, depth) {
99
+ if (text.i18nParams === undefined)
100
+ return translate(text.i18nKey);
101
+ const params = {};
102
+ for (const [name, param] of Object.entries(text.i18nParams)) {
103
+ if (isTextParam(param))
104
+ params[name] = resolveTextParam(param, translate, formats, depth);
105
+ }
106
+ return translate(text.i18nKey, params);
107
+ }
108
+ /** Plain strings pass through; DashboardI18nText is translated (nested params first).
109
+ * Anything else yields undefined so callers fall back to their own placeholder. */
110
+ function resolveDashboardText(value, translate, formats) {
111
+ if (typeof value === "string")
112
+ return value;
113
+ if (typeof value === "number")
114
+ return String(value);
115
+ if (isDashboardI18nText(value))
116
+ return resolveI18nText(value, translate, formats, 1);
117
+ return undefined;
118
+ }
52
119
  function PanelShell({ testId, title, subtitle, children, }) {
53
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 })] }));
54
121
  }
@@ -100,8 +167,13 @@ function readSparkline(panel, record) {
100
167
  function readTone(raw) {
101
168
  return typeof raw === "string" && STAT_TONES.has(raw) ? raw : undefined;
102
169
  }
103
- function StatPanelBody({ panel, label, screenId, screenParams, variant, }) {
104
- const { formatNumber } = useDashboardFormats();
170
+ function fallbackText(raw, placeholder) {
171
+ if (raw === undefined || raw === null || isRecord(raw))
172
+ return placeholder;
173
+ return String(raw);
174
+ }
175
+ function StatPanelBody({ panel, label, screenId, screenParams, translate, variant, }) {
176
+ const formats = useDashboardFormats();
105
177
  // Resolved HERE (not in a separate always-rendered child) so `icon` on
106
178
  // <StatCard> is `undefined` — not a React element that renders empty —
107
179
  // when the icon name isn't registered. StatCard gates its accent chip on
@@ -129,10 +201,12 @@ function StatPanelBody({ panel, label, screenId, screenParams, variant, }) {
129
201
  (rawValue === 0 ? undefined : panel.tone);
130
202
  const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
131
203
  const delta = readDelta(panel, record);
132
- const value = typeof rawValue === "number" ? formatNumber(rawValue) : String(rawValue ?? "—");
204
+ const value = typeof rawValue === "number"
205
+ ? formats.formatPanelValue(panel.valueFormat)(rawValue)
206
+ : (resolveDashboardText(rawValue, translate, formats) ?? fallbackText(rawValue, "—"));
133
207
  const spark = readSparkline(panel, record);
134
208
  const testId = `dashboard-panel-${panel.id}`;
135
- const subText = sub !== undefined && sub !== null ? String(sub) : undefined;
209
+ const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
136
210
  if (variant === "strip") {
137
211
  return (_jsx(StatStripCell, { label: label, value: value, tone: tone ?? "default", ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
138
212
  }
@@ -153,9 +227,15 @@ function readDelta(panel, record) {
153
227
  function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }) {
154
228
  const testId = `dashboard-panel-${panel.id}`;
155
229
  if (label === undefined) {
156
- 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, variant: "strip" }, stat.id))) }));
230
+ 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))) }));
157
231
  }
158
- 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, variant: "card" }, stat.id))) }) }));
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
+ }
234
+ function resolveMarkers(markers, translate, formats) {
235
+ return markers?.map((marker) => ({
236
+ atMs: marker.atMs,
237
+ label: resolveDashboardText(marker.label, translate, formats) ?? "",
238
+ }));
159
239
  }
160
240
  // A plain bar chart ("changes per day") ships `points` without `series`.
161
241
  const SINGLE_SERIES_KEY = "value";
@@ -196,12 +276,14 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
196
276
  return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data) => {
197
277
  const series = chartSeries(data, label, translate);
198
278
  const { startMs, endMs } = windowOf(data, series);
279
+ const markers = resolveMarkers(data.markers, translate, formats);
280
+ const formatValue = formats.formatPanelValue(panel.valueFormat);
199
281
  if (panel.chart === "segment-bars") {
200
282
  return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
201
283
  ...row,
202
284
  label: translate(row.label),
203
285
  segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
204
- })), tones: tones, ariaLabel: label, formatValue: formats.formatNumber, testId: testId }));
286
+ })), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
205
287
  }
206
288
  if (panel.chart === "stacked-bars") {
207
289
  const times = series.flatMap((s) => s.points.map((p) => p.atMs));
@@ -211,13 +293,15 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
211
293
  const prev = all[i - 1];
212
294
  return prev === undefined ? [] : [atMs - prev];
213
295
  }), DAY_MS);
214
- return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formats.formatNumber, testId: testId }));
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 }));
215
297
  }
216
298
  if (panel.chart === "stacked-area") {
217
299
  return (_jsx(StackedAreaChart, { series: series, windowStartMs: startMs, windowEndMs: endMs, ...(data.todayMs !== undefined &&
218
- data.todayMs !== null && { todayMs: data.todayMs }), tones: tones, markers: data.markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formats.formatNumber, formatMarkerTime: formats.formatDay, testId: testId }));
300
+ data.todayMs !== null && { todayMs: data.todayMs }), tones: tones, markers: markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formatValue, formatMarkerTime: formats.formatDay, scrollable: panel.scrollable === true, testId: testId }));
219
301
  }
220
- return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: data.markers, formatMarkerTime: formats.formatDay, ariaLabel: label, testId: testId }));
302
+ return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
303
+ yAxis: { ticks: 3, format: formatValue },
304
+ }), ariaLabel: label, testId: testId }));
221
305
  } }));
222
306
  }
223
307
  function ListPanelBody({ panel, label, screenParams, translate, }) {
@@ -236,19 +320,27 @@ function ListPanelBody({ panel, label, screenParams, translate, }) {
236
320
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, formatDateTime: formatDateTime, translate: translate })) }));
237
321
  }
238
322
  function FeedPanelBody({ panel, label, screenParams, translate, }) {
323
+ const formats = useDashboardFormats();
239
324
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
240
- const rows = (data.rows ?? []).map((row, i) => ({
241
- id: String(i),
242
- ...row,
243
- }));
325
+ const rows = (data.rows ?? []).map((row, i) => {
326
+ const trailing = resolveDashboardText(row.trailing, translate, formats);
327
+ return {
328
+ id: String(i),
329
+ primary: resolveDashboardText(row.primary, translate, formats) ?? "—",
330
+ ...(trailing !== undefined && { trailing }),
331
+ };
332
+ });
244
333
  return _jsx(FeedList, { rows: rows });
245
334
  } }));
246
335
  }
247
336
  function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
337
+ const formats = useDashboardFormats();
248
338
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
249
339
  const rows = (data.rows ?? []).map((row, i) => ({
250
340
  id: String(i),
251
- ...row,
341
+ label: resolveDashboardText(row.label, translate, formats) ?? "—",
342
+ value: resolveDashboardText(row.value, translate, formats) ?? "—",
343
+ fraction: row.fraction,
252
344
  }));
253
345
  return _jsx(ProgressList, { rows: rows });
254
346
  } }));
@@ -333,7 +425,7 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
333
425
  }
334
426
  const text = label ?? "";
335
427
  if (panel.kind === "stat") {
336
- return (_jsx(StatPanelBody, { panel: panel, label: text, screenId: screenId, screenParams: screenParams, variant: "card" }));
428
+ return (_jsx(StatPanelBody, { panel: panel, label: text, screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }));
337
429
  }
338
430
  if (panel.kind === "chart") {
339
431
  return (_jsx(ChartPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
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
- function pushPath(path) {
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
- // Nur pushen wenn sich der Pfad wirklich ändert — doppelte navigate()
128
- // Aufrufe mit demselben Ziel sollen nicht die History fluten.
129
- if (window.location.pathname === path)
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
  };
@@ -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
- function RowActionButton({ row, action, iconOnly = false, asLink = false, }) {
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
- // "icon" without a resolvable icon would render an empty button.
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
@@ -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
  }
@@ -14,4 +14,4 @@ export type MoneyInputProps = {
14
14
  readonly hasError?: boolean;
15
15
  };
16
16
  export declare function MoneyInput({ id, name, value, onChange, currency, locale, disabled, required, hasError, }: MoneyInputProps): ReactNode;
17
- export declare function formatMoney(amountMinor: number, currency: string, locale?: string): string;
17
+ export declare function formatMoney(amountMinor: number, currency: string, locale?: string, fractionDigits?: number): string;
@@ -88,16 +88,17 @@ export function MoneyInput({ id, name, value, onChange, currency, locale, disabl
88
88
  // rehydrateMoney) throws a RangeError inside Intl.NumberFormat, and with
89
89
  // no ErrorBoundary in this render tree that would take down the whole
90
90
  // page instead of just this cell.
91
- export function formatMoney(amountMinor, currency, locale) {
91
+ export function formatMoney(amountMinor, currency, locale, fractionDigits) {
92
92
  if (!/^[A-Za-z]{3}$/.test(currency))
93
93
  return String(amountMinor);
94
94
  const decimals = currencyDecimals(currency);
95
+ const displayDigits = fractionDigits ?? decimals;
95
96
  const resolvedLocale = resolveSafeLocale(locale);
96
97
  return new Intl.NumberFormat(resolvedLocale, {
97
98
  style: "currency",
98
99
  currency,
99
- minimumFractionDigits: decimals,
100
- maximumFractionDigits: decimals,
100
+ minimumFractionDigits: displayDigits,
101
+ maximumFractionDigits: displayDigits,
101
102
  }).format(amountMinor / 10 ** decimals);
102
103
  }
103
104
  // The currency symbol is rendered as a separate adornment, so the field
@@ -133,7 +133,7 @@ export declare function SegmentBarChart({ rows, tones, ariaLabel, formatValue, t
133
133
  }): ReactNode;
134
134
  /** Stacked bands over time. Right of `todayMs` is the forecast: lighter
135
135
  * fill plus a vertical "today" line. */
136
- export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, emptyContent, testId, }: {
136
+ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, emptyContent, testId, }: {
137
137
  readonly series: readonly ChartSeries[];
138
138
  readonly windowStartMs: number;
139
139
  readonly windowEndMs: number;
@@ -145,6 +145,8 @@ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, t
145
145
  readonly formatBucketLabel: (atMs: number) => string;
146
146
  readonly formatValue?: (value: number) => string;
147
147
  readonly formatMarkerTime?: (atMs: number) => string;
148
+ /** Fixed width per bucket; the plot scrolls horizontally, y ticks stay put. */
149
+ readonly scrollable?: boolean;
148
150
  readonly emptyContent?: ReactNode;
149
151
  readonly testId?: string;
150
152
  }): ReactNode;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from "react";
2
+ import { useEffect, useId, useRef } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import { STATUS_TONE_TEXT } from "./status-badge.js";
5
5
  // Inline-SVG-Charts — kein Chart-Dep. Farben ausschließlich über die
@@ -241,17 +241,35 @@ function MarkerPins({ markers, windowStartMs, windowEndMs, }) {
241
241
  function MarkerLegend({ markers, formatMarkerTime, }) {
242
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) }))] }, `${marker.atMs}-${marker.label}`))) }));
243
243
  }
244
+ const SCROLLABLE_BUCKET_WIDTH_PX = 48;
245
+ function bucketLabelTransform(index, count) {
246
+ if (index === 0)
247
+ return "";
248
+ return index === count - 1 ? "-translate-x-full" : "-translate-x-1/2";
249
+ }
244
250
  /** Stacked bands over time. Right of `todayMs` is the forecast: lighter
245
251
  * fill plus a vertical "today" line. */
246
- export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, emptyContent, testId, }) {
252
+ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, emptyContent, testId, }) {
247
253
  const clipId = useId();
254
+ const scrollRef = useRef(null);
248
255
  const times = bucketTimes(series);
256
+ const bucketCount = times.length;
257
+ const span = Math.max(1, windowEndMs - windowStartMs);
258
+ const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
259
+ const todayFraction = todayMs !== undefined ? fractionOf(todayMs) : undefined;
260
+ useEffect(() => {
261
+ const el = scrollRef.current;
262
+ if (!scrollable || el === null || bucketCount === 0)
263
+ return;
264
+ el.scrollLeft =
265
+ todayFraction === undefined
266
+ ? el.scrollWidth
267
+ : Math.max(0, todayFraction * el.scrollWidth - el.clientWidth / 2);
268
+ }, [scrollable, todayFraction, bucketCount]);
249
269
  if (times.length === 0)
250
270
  return _jsx("div", { "data-testid": testId, children: emptyContent });
251
271
  const width = 300;
252
272
  const height = 120;
253
- const span = Math.max(1, windowEndMs - windowStartMs);
254
- const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
255
273
  const totals = times.map((atMs) => series.reduce((sum, s) => sum + valueAt(s, atMs), 0));
256
274
  const isSingleBucket = times.length === 1;
257
275
  // Doubling keeps a lone bucket away from the top edge of the y-scale.
@@ -275,10 +293,13 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
275
293
  dot: isSingleBucket && bucketHeight > 0 ? clampDotY(yOf(bucketTop), height) : undefined,
276
294
  };
277
295
  });
278
- const todayFraction = todayMs !== undefined ? fractionOf(todayMs) : undefined;
279
296
  const todayX = (todayFraction ?? 1) * width;
280
297
  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)));
281
- 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: "h-32 pb-0" }), _jsxs("div", { className: "relative grow", 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) })), 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" }))] }), _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 }))] })] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
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) })), 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
+ 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
+ minWidth: "100%",
301
+ width: `${times.length * SCROLLABLE_BUCKET_WIDTH_PX}px`,
302
+ }, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
282
303
  key: s.key,
283
304
  label: s.label,
284
305
  color: chartColor(s.key, i, tones),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.348.1",
3
+ "version": "0.350.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.348.1",
24
- "@cosmicdrift/kumiko-framework": "0.348.1",
25
- "@cosmicdrift/kumiko-headless": "0.348.1",
26
- "@cosmicdrift/kumiko-renderer": "0.348.1",
27
- "@cosmicdrift/kumiko-types": "0.348.1",
23
+ "@cosmicdrift/kumiko-dispatcher-live": "0.350.0",
24
+ "@cosmicdrift/kumiko-framework": "0.350.0",
25
+ "@cosmicdrift/kumiko-headless": "0.350.0",
26
+ "@cosmicdrift/kumiko-renderer": "0.350.0",
27
+ "@cosmicdrift/kumiko-types": "0.350.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.348.1"
75
+ "@cosmicdrift/kumiko-locale-de": "0.350.0"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.350.0",
4
+ "type": "improvement",
5
+ "title": "Row actions with display \"responsive\", Lightbox actions and showPosition, navigate with searchParams, facet counts refresh after writes",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.349.0",
10
+ "type": "improvement",
11
+ "title": "Dashboard panel texts are translatable (i18nKey/i18nParams with nested, money and date params), panels get a currency valueFormat and stacked-area charts can scroll",
12
+ "detail": "Dashboard panels: translatable texts, currency format, scrollable stacked-area\nQuery handlers can send `{ i18nKey, i18nParams? }` (`DashboardI18nText`) wherever a dashboard panel shows text: stat value and sub line, feed `primary`/`trailing`, progress-list `label`/`value`, chart marker labels. Plain strings are shown unchanged. Params may be strings, numbers, nested `DashboardI18nText` (resolved first, e.g. a duration built from two plural keys), `{ kind: \"money\", amountMinor, currency }` (user locale) or `{ kind: \"date\", atMs }` (medium date, user time zone).\n`valueFormat: { kind: \"currency\", currency, fractionDigits? }` on stat and chart panels formats minor-unit values as currency in stat values, y ticks, legend totals and tooltips. `scrollable: true` on a `stacked-area` chart gives each bucket a fixed width, scrolls the plot horizontally with the y ticks fixed on the left, and opens at \"today\". The boot validator rejects an invalid currency code, `fractionDigits` outside 0..4 and `scrollable` on other chart kinds. `formatMoney` takes an optional `fractionDigits`."
13
+ },
2
14
  {
3
15
  "version": "0.342.0",
4
16
  "type": "improvement",