@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.
- package/dist/app/dashboard-body.js +108 -16
- package/dist/app/nav.js +19 -10
- package/dist/primitives/index.js +17 -9
- package/dist/primitives/lightbox.js +2 -2
- package/dist/primitives/money-input.d.ts +1 -1
- package/dist/primitives/money-input.js +4 -3
- package/dist/widgets/charts.d.ts +3 -1
- package/dist/widgets/charts.js +27 -6
- package/package.json +7 -7
- package/src/changes.json +12 -0
|
@@ -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
|
|
104
|
-
|
|
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"
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
// undefined = the caller passed no search params; "" = an explicitly empty query.
|
|
125
|
+
function formatSearch(options) {
|
|
126
|
+
if (options?.searchParams === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
const query = new URLSearchParams(options.searchParams).toString();
|
|
129
|
+
return query === "" ? "" : `?${query}`;
|
|
130
|
+
}
|
|
131
|
+
function pushPath(path, search) {
|
|
125
132
|
if (typeof window === "undefined")
|
|
126
133
|
return;
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
134
|
+
// Repeated navigate() calls to the same URL must not flood the history.
|
|
135
|
+
// Without explicit search params the current query counts as unchanged.
|
|
136
|
+
const unchanged = window.location.pathname === path &&
|
|
137
|
+
(search === undefined || window.location.search === search);
|
|
138
|
+
if (unchanged)
|
|
130
139
|
return;
|
|
131
|
-
window.history.pushState(null, "", path);
|
|
140
|
+
window.history.pushState(null, "", `${path}${search ?? ""}`);
|
|
132
141
|
for (const l of listeners)
|
|
133
142
|
l();
|
|
134
143
|
}
|
|
135
|
-
function replacePath(path) {
|
|
144
|
+
function replacePath(path, search) {
|
|
136
145
|
if (typeof window === "undefined")
|
|
137
146
|
return;
|
|
138
147
|
// No path-change short-circuit here: callers explicitly chose replace
|
|
139
148
|
// to avoid creating a history entry, even when the URL is identical.
|
|
140
149
|
// (pushPath skips no-op pushes; replacePath honors the call so the
|
|
141
150
|
// entry-stack semantics stay predictable.)
|
|
142
|
-
window.history.replaceState(null, "", path);
|
|
151
|
+
window.history.replaceState(null, "", `${path}${search ?? ""}`);
|
|
143
152
|
for (const l of listeners)
|
|
144
153
|
l();
|
|
145
154
|
}
|
|
@@ -197,9 +206,9 @@ export function useBrowserNavApi(options) {
|
|
|
197
206
|
const toPath = (target) => prependBasePath(formatPath(inCurrentWorkspace(resolveTarget(features, target))), basePath);
|
|
198
207
|
return {
|
|
199
208
|
route,
|
|
200
|
-
navigate: (target) => pushPath(toPath(target)),
|
|
201
|
-
replace: (target) => replacePath(toPath(target)),
|
|
202
|
-
hrefFor: (target) => toPath(target)
|
|
209
|
+
navigate: (target, navigateOptions) => pushPath(toPath(target), formatSearch(navigateOptions)),
|
|
210
|
+
replace: (target, navigateOptions) => replacePath(toPath(target), formatSearch(navigateOptions)),
|
|
211
|
+
hrefFor: (target, navigateOptions) => `${toPath(target)}${formatSearch(navigateOptions) ?? ""}`,
|
|
203
212
|
searchParams,
|
|
204
213
|
setSearchParams: applySearchParamUpdates,
|
|
205
214
|
};
|
package/dist/primitives/index.js
CHANGED
|
@@ -504,7 +504,7 @@ const EXPAND_TOGGLE_CELL_CLASS = "w-10 px-1! py-0!";
|
|
|
504
504
|
const EXPANSION_CELL_CLASS = "whitespace-normal bg-muted p-0! align-top md:[&_td:last-child]:bg-muted";
|
|
505
505
|
const EXPANSION_CONTENT_CLASS = "pt-1 pr-4 pb-3.5 pl-[52px]";
|
|
506
506
|
function RowExpandToggle({ expanded, controlsId, label, onToggle, testId, }) {
|
|
507
|
-
return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
|
|
507
|
+
return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0 max-md:size-11", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
|
|
508
508
|
e.stopPropagation();
|
|
509
509
|
onToggle();
|
|
510
510
|
}, onKeyDown: (e) => e.stopPropagation(), children: _jsx(ChevronRight, { className: cn("size-4 transition-transform", expanded && "rotate-90"), "aria-hidden": "true" }) }));
|
|
@@ -670,7 +670,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
670
670
|
e.preventDefault();
|
|
671
671
|
e.stopPropagation();
|
|
672
672
|
onRowClick(row);
|
|
673
|
-
}, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink }) }))] }, row.id));
|
|
673
|
+
}, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink, compact: true }) }))] }, row.id));
|
|
674
674
|
if (!isRowExpandable)
|
|
675
675
|
return rowItem;
|
|
676
676
|
// The expansion is a sibling <li>, outside the row's click target, so
|
|
@@ -764,7 +764,7 @@ function primaryRowAction(actions) {
|
|
|
764
764
|
// The isVisible filter runs here; an action hidden for a given row never
|
|
765
765
|
// reaches render. If every action is hidden, the cell stays empty (no
|
|
766
766
|
// phantom column).
|
|
767
|
-
function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, }) {
|
|
767
|
+
function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, compact = false, }) {
|
|
768
768
|
const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
|
|
769
769
|
if (visible.length === 0)
|
|
770
770
|
return null;
|
|
@@ -774,12 +774,12 @@ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, })
|
|
|
774
774
|
// otherwise "inline" is exactly the wall-to-wall text-button problem
|
|
775
775
|
// this feature exists to fix. An author-set `display` takes that choice over.
|
|
776
776
|
const iconOnly = !hasExplicitDisplay && shouldRenderActionsIconOnly(visible);
|
|
777
|
-
return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly }, a.id))) }));
|
|
777
|
+
return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly, compact: compact }, a.id))) }));
|
|
778
778
|
}
|
|
779
779
|
if (hasExplicitDisplay) {
|
|
780
780
|
const inline = visible.filter((a) => a.display !== undefined);
|
|
781
781
|
const rest = visible.filter((a) => a.display === undefined);
|
|
782
|
-
return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
|
|
782
|
+
return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
|
|
783
783
|
}
|
|
784
784
|
if (rowIsLink) {
|
|
785
785
|
return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
|
|
@@ -890,12 +890,20 @@ function useRowActionTrigger(row) {
|
|
|
890
890
|
};
|
|
891
891
|
return { busy, triggerNow };
|
|
892
892
|
}
|
|
893
|
-
|
|
893
|
+
// An icon-only form without a resolvable icon would render an empty button.
|
|
894
|
+
// `compact` is the narrow card layout, where "responsive" collapses to its icon.
|
|
895
|
+
function effectiveRowActionDisplay(display, hasIcon, compact) {
|
|
896
|
+
if (display === "responsive")
|
|
897
|
+
return hasIcon && compact ? "icon" : "button";
|
|
898
|
+
if (display === "icon" && !hasIcon)
|
|
899
|
+
return "link";
|
|
900
|
+
return display;
|
|
901
|
+
}
|
|
902
|
+
function RowActionButton({ row, action, iconOnly = false, asLink = false, compact = false, }) {
|
|
894
903
|
const { busy, triggerNow } = useRowActionTrigger(row);
|
|
895
904
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
896
905
|
const resolvedIcon = actionIconFor(action.icon);
|
|
897
|
-
|
|
898
|
-
const display = action.display === "icon" && resolvedIcon === undefined ? "link" : action.display;
|
|
906
|
+
const display = effectiveRowActionDisplay(action.display, resolvedIcon !== undefined, compact);
|
|
899
907
|
const isBorderedButton = display === "button";
|
|
900
908
|
const showIconOnly = display === "icon" || (display === undefined && iconOnly && resolvedIcon !== undefined);
|
|
901
909
|
const linkLike = display === "link" || (display === undefined && asLink);
|
|
@@ -924,7 +932,7 @@ function RowActionButton({ row, action, iconOnly = false, asLink = false, }) {
|
|
|
924
932
|
? "h-8 rounded-md px-3 font-medium max-md:h-11"
|
|
925
933
|
: linkLike
|
|
926
934
|
? "h-7 rounded-md px-2.5 max-md:h-11"
|
|
927
|
-
: "h-8 rounded-sm", showIconOnly ? "w-8" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
|
|
935
|
+
: "h-8 rounded-sm max-md:h-11", showIconOnly ? "w-8 max-md:w-11" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
|
|
928
936
|
}
|
|
929
937
|
// Kebab dropdown for >2 actions, one inline confirm dialog per item like
|
|
930
938
|
// the inline-button path. Menu is controlled so it closes explicitly on
|
|
@@ -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:
|
|
100
|
-
maximumFractionDigits:
|
|
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
|
package/dist/widgets/charts.d.ts
CHANGED
|
@@ -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;
|
package/dist/widgets/charts.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.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.
|
|
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",
|