@cosmicdrift/kumiko-renderer-web 0.220.1 → 0.222.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/package.json +5 -5
- package/src/__tests__/form-action-bar.test.tsx +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +60 -5
- package/src/__tests__/nav-tree.test.tsx +6 -14
- package/src/__tests__/primitives.test.tsx +83 -3
- package/src/__tests__/render-edit.test.tsx +8 -14
- package/src/__tests__/render-list-unit-locale.test.tsx +125 -0
- package/src/app/__tests__/document-lang-sync.test.tsx +43 -0
- package/src/app/client-plugin.tsx +2 -1
- package/src/app/document-lang-sync.tsx +14 -8
- package/src/app/plain-content-editor.tsx +6 -2
- package/src/lib/__tests__/download.test.ts +3 -3
- package/src/lib/__tests__/download.test.tsx +30 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/date-parse.test.ts +6 -3
- package/src/primitives/__tests__/embedded-list-input.test.tsx +21 -0
- package/src/primitives/date-parse.ts +2 -0
- package/src/primitives/index.tsx +55 -33
- package/src/ui/sheet.tsx +2 -15
- package/src/widgets/__tests__/feed-list.test.tsx +3 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +17 -9
- package/src/widgets/__tests__/widgets.test.tsx +27 -0
- package/src/widgets/charts.tsx +46 -27
- package/src/widgets/drawer.tsx +22 -14
- package/src/widgets/infinity-list.tsx +41 -5
- package/src/widgets/sheet-parts.tsx +62 -0
- package/src/widgets/upload-zone.tsx +5 -5
|
@@ -209,6 +209,19 @@ describe("defaultCellRender", () => {
|
|
|
209
209
|
expect(new Intl.NumberFormat("de-DE").format(245.5)).toBe("245,5");
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
test("number/decimal → explicit locale param wins over the runtime default (fw#2437)", () => {
|
|
213
|
+
expect(defaultCellRender(245.5, "number", undefined, "de-DE")).toBe(
|
|
214
|
+
new Intl.NumberFormat("de-DE").format(245.5),
|
|
215
|
+
);
|
|
216
|
+
expect(defaultCellRender(245.5, "decimal", undefined, "en-US")).toBe(
|
|
217
|
+
new Intl.NumberFormat("en-US").format(245.5),
|
|
218
|
+
);
|
|
219
|
+
// Same value, two locales → two different strings, not just "locale is accepted".
|
|
220
|
+
expect(defaultCellRender(245.5, "number", undefined, "de-DE")).not.toBe(
|
|
221
|
+
defaultCellRender(245.5, "number", undefined, "en-US"),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
212
225
|
test("money → { amount, currency } formatiert, kein [object Object]", () => {
|
|
213
226
|
const result = defaultCellRender({ amount: 450, currency: "EUR" }, "money");
|
|
214
227
|
expect(result).not.toBe("[object Object]");
|
|
@@ -254,4 +267,18 @@ describe("defaultCellRender", () => {
|
|
|
254
267
|
expect(() => defaultCellRender({ amount: 1, currency: "" }, "money")).not.toThrow();
|
|
255
268
|
expect(defaultCellRender({ amount: 1, currency: "" }, "money")).toBe("[object Object]");
|
|
256
269
|
});
|
|
270
|
+
|
|
271
|
+
test("money → explicit locale param wins over guessLocale's navigator fallback (fw#2437)", () => {
|
|
272
|
+
const value = { amount: 1234.5, currency: "EUR" };
|
|
273
|
+
const de = defaultCellRender(value, "money", undefined, "de-DE");
|
|
274
|
+
const en = defaultCellRender(value, "money", undefined, "en-US");
|
|
275
|
+
// Same amount, two locales → two different strings, not just "locale is accepted".
|
|
276
|
+
expect(de).not.toBe(en);
|
|
277
|
+
expect(de).toBe(
|
|
278
|
+
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5),
|
|
279
|
+
);
|
|
280
|
+
expect(en).toBe(
|
|
281
|
+
new Intl.NumberFormat("en-US", { style: "currency", currency: "EUR" }).format(1234.5),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
257
284
|
});
|
|
@@ -33,9 +33,12 @@ const enPlaceholderLetters = placeholderLetters("en");
|
|
|
33
33
|
describe("parseIso", () => {
|
|
34
34
|
test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
|
|
35
35
|
const d = parseIso("2026-04-25");
|
|
36
|
-
// Cross-realm safe:
|
|
37
|
-
//
|
|
38
|
-
|
|
36
|
+
// Cross-realm safe: assert against the same Temporal activeTemporal() uses
|
|
37
|
+
// (native when present), not the polyfill import — Bun 1.4+ ships Temporal.
|
|
38
|
+
const ActivePlainDate = (
|
|
39
|
+
(globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal
|
|
40
|
+
).PlainDate;
|
|
41
|
+
expect(d).toBeInstanceOf(ActivePlainDate);
|
|
39
42
|
expect(String(d)).toBe("2026-04-25");
|
|
40
43
|
expect(d?.year).toBe(2026);
|
|
41
44
|
expect(d?.month).toBe(4);
|
|
@@ -498,6 +498,27 @@ describe("EmbeddedListInput — paste", () => {
|
|
|
498
498
|
});
|
|
499
499
|
|
|
500
500
|
describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
|
|
501
|
+
test("Enter on a select trigger in the last cell does not append a row", () => {
|
|
502
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
503
|
+
{
|
|
504
|
+
field: "status",
|
|
505
|
+
label: "Status",
|
|
506
|
+
type: "select",
|
|
507
|
+
required: false,
|
|
508
|
+
derived: false,
|
|
509
|
+
options: ["open"],
|
|
510
|
+
},
|
|
511
|
+
];
|
|
512
|
+
const rows = [{ status: "open" }];
|
|
513
|
+
const onAddRow = mock(() => {});
|
|
514
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ columns, rows, onAddRow })} />);
|
|
515
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
516
|
+
const trigger = desktop.getByTestId("lines-cell-0-status").querySelector("button");
|
|
517
|
+
if (trigger === null) throw new Error("expected a select trigger button");
|
|
518
|
+
fireEvent.keyDown(trigger, { key: "Enter", code: "Enter" });
|
|
519
|
+
expect(onAddRow).not.toHaveBeenCalled();
|
|
520
|
+
});
|
|
521
|
+
|
|
501
522
|
test("Enter on the last editable cell of the last row fires onAddRow and prevents default, same as Tab", () => {
|
|
502
523
|
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
503
524
|
const onAddRow = mock(() => {});
|
|
@@ -10,6 +10,8 @@ import { Temporal } from "temporal-polyfill";
|
|
|
10
10
|
// Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
|
|
11
11
|
// polyfill so `instanceof` checks match values crossing package boundaries;
|
|
12
12
|
// falls back to the polyfill where native support is absent.
|
|
13
|
+
// Callers must not feed parseIso()/makePlainDate results into polyfill-only
|
|
14
|
+
// Statics (compare/equals/since) — mixed Temporal brands throw TypeError.
|
|
13
15
|
function activeTemporal(): typeof Temporal {
|
|
14
16
|
return (globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal;
|
|
15
17
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
|
|
9
9
|
// Dropdown etc. kommen später).
|
|
10
10
|
|
|
11
|
+
import type { FieldIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
12
|
import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
12
13
|
import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
13
14
|
import type {
|
|
@@ -36,6 +37,8 @@ import {
|
|
|
36
37
|
type StepBarProps,
|
|
37
38
|
type TextProps,
|
|
38
39
|
useColumnRenderer,
|
|
40
|
+
useOptionalLocale,
|
|
41
|
+
useOptionalTranslation,
|
|
39
42
|
useTranslation,
|
|
40
43
|
type WizardStepGroupProps,
|
|
41
44
|
WriteFailedError,
|
|
@@ -311,7 +314,7 @@ function DefaultField({
|
|
|
311
314
|
// (nav-tree.tsx) — a separate, smaller registry instead of a shared
|
|
312
315
|
// import, because field icons cover a different use case (email, phone,
|
|
313
316
|
// location, …) than nav icons (dashboard, tables, …).
|
|
314
|
-
const FIELD_ICONS
|
|
317
|
+
const FIELD_ICONS = {
|
|
315
318
|
mail: Mail,
|
|
316
319
|
lock: Lock,
|
|
317
320
|
hash: Hash,
|
|
@@ -325,10 +328,12 @@ const FIELD_ICONS: Readonly<Record<string, typeof Mail>> = {
|
|
|
325
328
|
globe: Globe,
|
|
326
329
|
key: KeyRound,
|
|
327
330
|
"map-pin": MapPin,
|
|
328
|
-
}
|
|
331
|
+
} as const satisfies Readonly<Record<FieldIconKey, typeof Mail>>;
|
|
329
332
|
|
|
330
|
-
function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[
|
|
331
|
-
return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
|
|
333
|
+
function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[FieldIconKey] | undefined {
|
|
334
|
+
return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
|
|
335
|
+
? FIELD_ICONS[icon as FieldIconKey]
|
|
336
|
+
: undefined;
|
|
332
337
|
}
|
|
333
338
|
|
|
334
339
|
// Wraps a text/number input with a left-positioned prefix icon when
|
|
@@ -668,6 +673,10 @@ function DefaultDataTable({
|
|
|
668
673
|
getRowTestId,
|
|
669
674
|
getCellTestId,
|
|
670
675
|
}: DataTableProps): ReactNode {
|
|
676
|
+
// One locale/translate subscription per table — not per cell (fw#2345).
|
|
677
|
+
// Optional hooks: a bare DataTable outside LocaleProvider must not crash.
|
|
678
|
+
const tableTranslate = useOptionalTranslation();
|
|
679
|
+
const tableLocale = useOptionalLocale();
|
|
671
680
|
// Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
|
|
672
681
|
// beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
|
|
673
682
|
// scrollen typischerweise mit dem Page-Container, nicht intern.
|
|
@@ -734,12 +743,6 @@ function DefaultDataTable({
|
|
|
734
743
|
// if the sum of the columns gets too wide.
|
|
735
744
|
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
736
745
|
title={cellTitle(row.values[col.field])}
|
|
737
|
-
// A click into the editable cell's widget must not also
|
|
738
|
-
// trigger the row's onClick (typically "Open Detail") —
|
|
739
|
-
// same reasoning as the actions cell below.
|
|
740
|
-
{...(onCellChange !== undefined && {
|
|
741
|
-
onClick: (e: MouseEvent) => e.stopPropagation(),
|
|
742
|
-
})}
|
|
743
746
|
>
|
|
744
747
|
<DataTableCell
|
|
745
748
|
value={row.values[col.field]}
|
|
@@ -747,6 +750,8 @@ function DefaultDataTable({
|
|
|
747
750
|
field={col.field}
|
|
748
751
|
type={col.type}
|
|
749
752
|
renderer={col.renderer}
|
|
753
|
+
translate={tableTranslate}
|
|
754
|
+
locale={tableLocale}
|
|
750
755
|
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
751
756
|
{...(onCellChange !== undefined && {
|
|
752
757
|
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
@@ -998,10 +1003,10 @@ function RowActionButton({
|
|
|
998
1003
|
);
|
|
999
1004
|
}
|
|
1000
1005
|
|
|
1001
|
-
// Kebab
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1006
|
+
// Kebab dropdown for >2 actions, one inline confirm dialog per item like
|
|
1007
|
+
// the inline-button path. Menu is controlled so it closes explicitly on
|
|
1008
|
+
// select, instead of relying on Radix's auto-close, which preventDefault
|
|
1009
|
+
// below blocks.
|
|
1005
1010
|
function RowActionsKebab({
|
|
1006
1011
|
row,
|
|
1007
1012
|
actions,
|
|
@@ -1011,10 +1016,11 @@ function RowActionsKebab({
|
|
|
1011
1016
|
}): ReactNode {
|
|
1012
1017
|
const { triggerNow } = useRowActionTrigger(row);
|
|
1013
1018
|
const [pendingConfirm, setPendingConfirm] = useState<DataTableRowAction | null>(null);
|
|
1019
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
1014
1020
|
|
|
1015
1021
|
return (
|
|
1016
1022
|
<>
|
|
1017
|
-
<DropdownMenu>
|
|
1023
|
+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
1018
1024
|
<DropdownMenuTrigger asChild>
|
|
1019
1025
|
<button
|
|
1020
1026
|
type="button"
|
|
@@ -1036,6 +1042,7 @@ function RowActionsKebab({
|
|
|
1036
1042
|
data-testid={`row-${row.id}-action-${action.id}`}
|
|
1037
1043
|
onSelect={(e) => {
|
|
1038
1044
|
e.preventDefault();
|
|
1045
|
+
setMenuOpen(false);
|
|
1039
1046
|
if (needsConfirm(action)) {
|
|
1040
1047
|
setPendingConfirm(action);
|
|
1041
1048
|
} else {
|
|
@@ -1442,25 +1449,18 @@ export function defaultCellRender(
|
|
|
1442
1449
|
value: unknown,
|
|
1443
1450
|
type: string,
|
|
1444
1451
|
optionLabels?: Readonly<Record<string, string>>,
|
|
1452
|
+
locale?: string,
|
|
1445
1453
|
): string {
|
|
1446
1454
|
if (value === null || value === undefined || value === "") return "";
|
|
1447
1455
|
if (type === "boolean") return value === true ? "✓" : "";
|
|
1448
|
-
if (type === "timestamp" || type === "date")
|
|
1456
|
+
if (type === "timestamp" || type === "date") {
|
|
1457
|
+
return applyFormatSpec({ format: type, locale }, value);
|
|
1458
|
+
}
|
|
1449
1459
|
if (type === "number" || type === "decimal" || type === "bigInt") {
|
|
1450
|
-
|
|
1451
|
-
// DataTableCell has no app-locale context to thread through yet.
|
|
1452
|
-
// Intl.NumberFormat(undefined) resolves the runtime's default locale.
|
|
1453
|
-
return applyFormatSpec({ format: type }, value);
|
|
1460
|
+
return applyFormatSpec({ format: type, locale }, value);
|
|
1454
1461
|
}
|
|
1455
1462
|
if (type === "money") {
|
|
1456
1463
|
if (!isMoneyValue(value)) return String(value);
|
|
1457
|
-
// No `locale` param here: DataTableCell has no app-locale context to
|
|
1458
|
-
// thread through (unlike the form path — see MoneyInput/RenderField in
|
|
1459
|
-
// packages/renderer, which pins the LocaleProvider locale). formatMoney
|
|
1460
|
-
// falls back to guessLocale() (navigator.language), so the same amount
|
|
1461
|
-
// can render differently in a table vs. a form on the same screen.
|
|
1462
|
-
// Mid-term: pass the app locale down here too, analogous to render-field.
|
|
1463
|
-
//
|
|
1464
1464
|
// formatMoney expects minor units scaled by currencyDecimals(currency).
|
|
1465
1465
|
// rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
|
|
1466
1466
|
// instead, which disagrees with currencyDecimals for non-2-decimal
|
|
@@ -1468,7 +1468,7 @@ export function defaultCellRender(
|
|
|
1468
1468
|
// low). Deriving minor units from `amount` keeps this consistent with
|
|
1469
1469
|
// render-field.tsx's moneyMinorValue.
|
|
1470
1470
|
const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
|
|
1471
|
-
return formatMoney(minor, value.currency);
|
|
1471
|
+
return formatMoney(minor, value.currency, locale);
|
|
1472
1472
|
}
|
|
1473
1473
|
if (type === "select") {
|
|
1474
1474
|
const raw = String(value);
|
|
@@ -1508,6 +1508,8 @@ type DataTableCellProps = {
|
|
|
1508
1508
|
readonly renderer?: unknown;
|
|
1509
1509
|
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
1510
1510
|
readonly onChange?: (value: unknown) => void;
|
|
1511
|
+
readonly translate?: (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
1512
|
+
readonly locale?: string;
|
|
1511
1513
|
};
|
|
1512
1514
|
|
|
1513
1515
|
// Cell-Renderer als Component (statt reiner Funktion) damit der
|
|
@@ -1527,12 +1529,17 @@ function DataTableCell({
|
|
|
1527
1529
|
renderer,
|
|
1528
1530
|
optionLabels,
|
|
1529
1531
|
onChange,
|
|
1532
|
+
translate,
|
|
1533
|
+
locale,
|
|
1530
1534
|
}: DataTableCellProps): ReactNode {
|
|
1531
1535
|
const componentRef = isComponentRendererRef(renderer);
|
|
1532
1536
|
const ResolvedComponent = useColumnRenderer(componentRef?.name);
|
|
1533
|
-
const t = useTranslation();
|
|
1534
1537
|
if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
|
|
1535
|
-
return applyFormatSpec(
|
|
1538
|
+
return applyFormatSpec(
|
|
1539
|
+
{ locale, ...(renderer as { format: string } & Record<string, unknown>) },
|
|
1540
|
+
value,
|
|
1541
|
+
translate,
|
|
1542
|
+
);
|
|
1536
1543
|
}
|
|
1537
1544
|
if (typeof renderer === "function") {
|
|
1538
1545
|
const fn = renderer as (v: unknown, r?: Readonly<Record<string, unknown>>) => string;
|
|
@@ -1540,7 +1547,7 @@ function DataTableCell({
|
|
|
1540
1547
|
}
|
|
1541
1548
|
if (componentRef !== undefined) {
|
|
1542
1549
|
if (ResolvedComponent !== undefined) {
|
|
1543
|
-
|
|
1550
|
+
const node = (
|
|
1544
1551
|
<ResolvedComponent
|
|
1545
1552
|
value={value}
|
|
1546
1553
|
row={row}
|
|
@@ -1548,6 +1555,21 @@ function DataTableCell({
|
|
|
1548
1555
|
{...(onChange !== undefined && { onChange })}
|
|
1549
1556
|
/>
|
|
1550
1557
|
);
|
|
1558
|
+
// stopPropagation only on the editable branch — plain cells must still
|
|
1559
|
+
// fire row onClick when onCellChange is set for sibling editable cells.
|
|
1560
|
+
if (onChange !== undefined) {
|
|
1561
|
+
return (
|
|
1562
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation only — not a control
|
|
1563
|
+
<span
|
|
1564
|
+
className="contents"
|
|
1565
|
+
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
1566
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
1567
|
+
>
|
|
1568
|
+
{node}
|
|
1569
|
+
</span>
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
return node;
|
|
1551
1573
|
}
|
|
1552
1574
|
// Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
|
|
1553
1575
|
// typischer Fall: clientFeatures.columnRenderers vergessen oder
|
|
@@ -1562,11 +1584,11 @@ function DataTableCell({
|
|
|
1562
1584
|
// dashboard-01-Muster: outline-Badge + muted statt gefülltem secondary.
|
|
1563
1585
|
return (
|
|
1564
1586
|
<Badge variant="outline" className="px-1.5 text-muted-foreground">
|
|
1565
|
-
{defaultCellRender(value, type, optionLabels)}
|
|
1587
|
+
{defaultCellRender(value, type, optionLabels, locale)}
|
|
1566
1588
|
</Badge>
|
|
1567
1589
|
);
|
|
1568
1590
|
}
|
|
1569
|
-
return defaultCellRender(value, type, optionLabels);
|
|
1591
|
+
return defaultCellRender(value, type, optionLabels, locale);
|
|
1570
1592
|
}
|
|
1571
1593
|
|
|
1572
1594
|
// ---- Form + Section + Grid + Text ----
|
package/src/ui/sheet.tsx
CHANGED
|
@@ -47,8 +47,6 @@ function SheetOverlay({
|
|
|
47
47
|
|
|
48
48
|
function SheetContent({
|
|
49
49
|
className,
|
|
50
|
-
overlayClassName,
|
|
51
|
-
overlayStyle,
|
|
52
50
|
children,
|
|
53
51
|
side = "right",
|
|
54
52
|
showCloseButton = true,
|
|
@@ -56,12 +54,10 @@ function SheetContent({
|
|
|
56
54
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
57
55
|
side?: "top" | "right" | "bottom" | "left"
|
|
58
56
|
showCloseButton?: boolean
|
|
59
|
-
overlayClassName?: string
|
|
60
|
-
overlayStyle?: React.CSSProperties
|
|
61
57
|
}) {
|
|
62
58
|
return (
|
|
63
59
|
<SheetPortal>
|
|
64
|
-
<SheetOverlay
|
|
60
|
+
<SheetOverlay />
|
|
65
61
|
<SheetPrimitive.Content
|
|
66
62
|
data-slot="sheet-content"
|
|
67
63
|
className={cn(
|
|
@@ -104,16 +100,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
104
100
|
return (
|
|
105
101
|
<div
|
|
106
102
|
data-slot="sheet-footer"
|
|
107
|
-
|
|
108
|
-
// + cardFooterBorder) for padding/border/button-row shape, but keeps
|
|
109
|
-
// the panel's own `bg-background` instead of the card footer's
|
|
110
|
-
// `bg-muted/30` — the sheet panel isn't a card, so its footer reads
|
|
111
|
-
// as the same surface as the body above it. The border-t alone marks
|
|
112
|
-
// the footer boundary.
|
|
113
|
-
className={cn(
|
|
114
|
-
"mt-auto flex items-center justify-end gap-2 border-t bg-background px-[var(--card-padding)] py-4",
|
|
115
|
-
className,
|
|
116
|
-
)}
|
|
103
|
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
117
104
|
{...props}
|
|
118
105
|
/>
|
|
119
106
|
)
|
|
@@ -18,6 +18,9 @@ describe("FeedList", () => {
|
|
|
18
18
|
test("renders trailing content only when defined", () => {
|
|
19
19
|
render(<FeedList rows={rows} testId="feed" />);
|
|
20
20
|
expect(screen.getByText("3 min")).toBeTruthy();
|
|
21
|
+
const items = screen.getAllByRole("listitem");
|
|
22
|
+
expect(items[0]?.textContent).toContain("3 min");
|
|
23
|
+
expect(items[1]?.textContent).not.toContain("min");
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
test("renders the empty state when there are no rows", () => {
|
|
@@ -73,7 +73,7 @@ function renderWithLive(ui: ReactNode, dispatcher: Dispatcher, liveEvents: LiveE
|
|
|
73
73
|
type Row = { readonly id: string; readonly subject: string };
|
|
74
74
|
type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
|
|
75
75
|
|
|
76
|
-
function list(query: string) {
|
|
76
|
+
function list(query: string, live = false) {
|
|
77
77
|
return (
|
|
78
78
|
<InfinityList<Page, Row>
|
|
79
79
|
query={query}
|
|
@@ -82,6 +82,7 @@ function list(query: string) {
|
|
|
82
82
|
rowId={(row) => row.id}
|
|
83
83
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
84
84
|
testId="inbox"
|
|
85
|
+
live={live}
|
|
85
86
|
/>
|
|
86
87
|
);
|
|
87
88
|
}
|
|
@@ -255,14 +256,18 @@ describe("InfinityList", () => {
|
|
|
255
256
|
isSuccess: true,
|
|
256
257
|
data: { rows: [{ id: "m1", subject: "Bo-Treffer" }], nextCursor: null },
|
|
257
258
|
});
|
|
258
|
-
await
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
expect(screen.queryByText("Bo-Treffer")).toBeNull();
|
|
261
|
+
expect(screen.getByText("Bob-Treffer")).toBeTruthy();
|
|
262
|
+
});
|
|
261
263
|
});
|
|
262
264
|
|
|
263
265
|
// fw#1827: InfinityList used dispatcher.query directly and never subscribed
|
|
264
266
|
// to live events, so a solon inbox stayed stale until the user reloaded.
|
|
265
267
|
describe("Live-Mode", () => {
|
|
268
|
+
// fw#1829 debounces live refresh by 250ms; CI needs headroom beyond default 1s.
|
|
269
|
+
const liveWait = { timeout: 3000 } as const;
|
|
270
|
+
|
|
266
271
|
test("SSE-Event mergt nur die erste Seite, bereits geladene Folgeseiten bleiben erhalten", async () => {
|
|
267
272
|
const calls: Array<Readonly<Record<string, unknown>>> = [];
|
|
268
273
|
const dispatcher = createMockDispatcher({
|
|
@@ -304,6 +309,7 @@ describe("InfinityList", () => {
|
|
|
304
309
|
rowId={(row) => row.id}
|
|
305
310
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
306
311
|
testId="inbox"
|
|
312
|
+
live={true}
|
|
307
313
|
/>,
|
|
308
314
|
dispatcher,
|
|
309
315
|
fake.subscriber,
|
|
@@ -324,7 +330,7 @@ describe("InfinityList", () => {
|
|
|
324
330
|
});
|
|
325
331
|
});
|
|
326
332
|
|
|
327
|
-
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
|
|
333
|
+
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy(), liveWait);
|
|
328
334
|
expect(screen.getByText("Alt-1")).toBeTruthy();
|
|
329
335
|
expect(screen.getByText("Alt-2")).toBeTruthy();
|
|
330
336
|
expect(screen.getAllByText("Alt-1").length).toBe(1);
|
|
@@ -362,7 +368,7 @@ describe("InfinityList", () => {
|
|
|
362
368
|
});
|
|
363
369
|
const fake = makeFakeLiveEvents();
|
|
364
370
|
|
|
365
|
-
renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
|
|
371
|
+
renderWithLive(list("inbox:query:message:list", true), dispatcher, fake.subscriber);
|
|
366
372
|
|
|
367
373
|
await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
|
|
368
374
|
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
@@ -377,7 +383,7 @@ describe("InfinityList", () => {
|
|
|
377
383
|
});
|
|
378
384
|
});
|
|
379
385
|
|
|
380
|
-
await waitFor(() => expect(screen.queryByText("Erste")).toBeNull());
|
|
386
|
+
await waitFor(() => expect(screen.queryByText("Erste")).toBeNull(), liveWait);
|
|
381
387
|
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
382
388
|
});
|
|
383
389
|
|
|
@@ -421,6 +427,7 @@ describe("InfinityList", () => {
|
|
|
421
427
|
rowId={(row) => row.id}
|
|
422
428
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
423
429
|
testId="inbox"
|
|
430
|
+
live={true}
|
|
424
431
|
/>,
|
|
425
432
|
dispatcher,
|
|
426
433
|
fake.subscriber,
|
|
@@ -441,7 +448,7 @@ describe("InfinityList", () => {
|
|
|
441
448
|
});
|
|
442
449
|
});
|
|
443
450
|
|
|
444
|
-
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
|
|
451
|
+
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy(), liveWait);
|
|
445
452
|
expect(screen.queryByText("Erste")).toBeNull();
|
|
446
453
|
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
447
454
|
});
|
|
@@ -508,6 +515,7 @@ describe("InfinityList", () => {
|
|
|
508
515
|
rowId={(row) => row.id}
|
|
509
516
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
510
517
|
testId="inbox"
|
|
518
|
+
live={true}
|
|
511
519
|
/>,
|
|
512
520
|
dispatcher,
|
|
513
521
|
fake.subscriber,
|
|
@@ -540,7 +548,7 @@ describe("InfinityList", () => {
|
|
|
540
548
|
});
|
|
541
549
|
const fake = makeFakeLiveEvents();
|
|
542
550
|
|
|
543
|
-
renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
|
|
551
|
+
renderWithLive(list("inbox:query:message:list", true), dispatcher, fake.subscriber);
|
|
544
552
|
|
|
545
553
|
await waitFor(() => expect(resolvers.length).toBe(1));
|
|
546
554
|
|
|
@@ -318,6 +318,33 @@ describe("StatusBarChart", () => {
|
|
|
318
318
|
expect(container.querySelectorAll("rect").length).toBe(5);
|
|
319
319
|
expect(screen.getByText("heute")).toBeTruthy();
|
|
320
320
|
});
|
|
321
|
+
|
|
322
|
+
test("dense: fixed-size flat bars ohne Tick/Gradient, aria-Label + Tooltips bleiben", () => {
|
|
323
|
+
const { container } = render(
|
|
324
|
+
<StatusBarChart
|
|
325
|
+
dense
|
|
326
|
+
ariaLabel="Zahlungsmonate"
|
|
327
|
+
entries={[
|
|
328
|
+
{ key: "m1", level: 1, tone: "ok", label: "Januar: bezahlt" },
|
|
329
|
+
{ key: "m2", level: 0.5, tone: "bad", label: "Februar: offen" },
|
|
330
|
+
]}
|
|
331
|
+
/>,
|
|
332
|
+
);
|
|
333
|
+
const svg = container.querySelector("svg");
|
|
334
|
+
expect(screen.getByRole("img", { name: "Zahlungsmonate" })).toBeTruthy();
|
|
335
|
+
expect(svg?.getAttribute("class")).not.toContain("w-full");
|
|
336
|
+
expect(svg?.getAttribute("width")).not.toBeNull();
|
|
337
|
+
expect(container.querySelectorAll("linearGradient").length).toBe(0);
|
|
338
|
+
// 1 bar (non-last entry) + 1 bar + 1 last-highlight stripe (no tick) = 3 rects
|
|
339
|
+
expect(container.querySelectorAll("rect").length).toBe(3);
|
|
340
|
+
expect(container.querySelectorAll("title").length).toBe(3); // aria title + 2 entry tooltips
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("dense: leere Entries reservieren keine 36px-Höhe", () => {
|
|
344
|
+
const { container } = render(<StatusBarChart dense ariaLabel="Leer" entries={[]} />);
|
|
345
|
+
const placeholder = container.querySelector("div[aria-hidden]");
|
|
346
|
+
expect(placeholder?.className).toBe("h-3");
|
|
347
|
+
});
|
|
321
348
|
});
|
|
322
349
|
|
|
323
350
|
describe("TimeseriesChart", () => {
|
package/src/widgets/charts.tsx
CHANGED
|
@@ -23,15 +23,17 @@ export type StatusBarEntry = {
|
|
|
23
23
|
readonly label?: string;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
/** Status
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
/** Status bar strip (e.g. 90-day uptime): variable-height bars with
|
|
27
|
+
* gradient fade + tick line on top; the last entry gets a "now"
|
|
28
|
+
* accent stripe. `dense` swaps this for a flat-fill, fixed-size
|
|
29
|
+
* variant sized to fit a table cell instead of stretching to `w-full`. */
|
|
29
30
|
export function StatusBarChart({
|
|
30
31
|
entries,
|
|
31
32
|
ariaLabel,
|
|
32
33
|
startLabel,
|
|
33
34
|
endLabel,
|
|
34
35
|
highlightLast = true,
|
|
36
|
+
dense = false,
|
|
35
37
|
testId,
|
|
36
38
|
}: {
|
|
37
39
|
readonly entries: readonly StatusBarEntry[];
|
|
@@ -40,28 +42,34 @@ export function StatusBarChart({
|
|
|
40
42
|
readonly startLabel?: string;
|
|
41
43
|
readonly endLabel?: string;
|
|
42
44
|
readonly highlightLast?: boolean;
|
|
45
|
+
/** Compact variant for table cells: fixed intrinsic size (no `w-full` stretch), flat fills instead of gradients, no tick marks. */
|
|
46
|
+
readonly dense?: boolean;
|
|
43
47
|
readonly testId?: string;
|
|
44
48
|
}): ReactNode {
|
|
45
49
|
const gradPrefix = useId();
|
|
46
|
-
if (entries.length === 0) return <div className="h-9" aria-hidden />;
|
|
50
|
+
if (entries.length === 0) return <div className={dense ? "h-3" : "h-9"} aria-hidden />;
|
|
47
51
|
|
|
48
|
-
const chartHeight = 36;
|
|
49
|
-
const tickHeight = 1;
|
|
52
|
+
const chartHeight = dense ? 12 : 36;
|
|
53
|
+
const tickHeight = dense ? 0 : 1;
|
|
54
|
+
const barWidth = dense ? 3 : 1;
|
|
50
55
|
const barGap = 1;
|
|
51
56
|
const lastIdx = entries.length - 1;
|
|
57
|
+
const totalWidth = entries.length * (barWidth + barGap);
|
|
52
58
|
|
|
53
59
|
return (
|
|
54
60
|
<div data-testid={testId}>
|
|
55
61
|
<svg
|
|
56
|
-
viewBox={`0 0 ${
|
|
57
|
-
preserveAspectRatio="none"
|
|
58
|
-
|
|
62
|
+
viewBox={`0 0 ${totalWidth} ${chartHeight}`}
|
|
63
|
+
preserveAspectRatio={dense ? undefined : "none"}
|
|
64
|
+
width={dense ? totalWidth : undefined}
|
|
65
|
+
height={dense ? chartHeight : undefined}
|
|
66
|
+
className={dense ? "block" : "block h-9 w-full"}
|
|
59
67
|
role="img"
|
|
60
68
|
aria-label={ariaLabel}
|
|
61
69
|
>
|
|
62
70
|
<title>{ariaLabel}</title>
|
|
63
71
|
{entries.map((entry, idx) => {
|
|
64
|
-
const x = idx * (
|
|
72
|
+
const x = idx * (barWidth + barGap);
|
|
65
73
|
const level = Math.max(0, Math.min(1, entry.level));
|
|
66
74
|
const barHeight = (chartHeight - tickHeight) * level;
|
|
67
75
|
const barY = chartHeight - barHeight;
|
|
@@ -70,33 +78,44 @@ export function StatusBarChart({
|
|
|
70
78
|
const gradId = `${gradPrefix}-${idx}`;
|
|
71
79
|
return (
|
|
72
80
|
<g key={entry.key}>
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
{!dense && (
|
|
82
|
+
<defs>
|
|
83
|
+
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
|
84
|
+
<stop offset="0%" stopColor={color} stopOpacity={isLast ? 0.85 : 0.5} />
|
|
85
|
+
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
|
|
86
|
+
</linearGradient>
|
|
87
|
+
</defs>
|
|
88
|
+
)}
|
|
79
89
|
{isLast && (
|
|
80
90
|
<rect
|
|
81
|
-
x={x -
|
|
91
|
+
x={x - barGap / 2}
|
|
82
92
|
y={0}
|
|
83
|
-
width={
|
|
93
|
+
width={barWidth + barGap}
|
|
84
94
|
height={chartHeight}
|
|
85
95
|
fill="var(--color-foreground)"
|
|
86
96
|
fillOpacity={0.06}
|
|
87
97
|
/>
|
|
88
98
|
)}
|
|
89
|
-
<rect x={x} y={barY} width={1} height={barHeight} fill={`url(#${gradId})`}>
|
|
90
|
-
{entry.label !== undefined && <title>{entry.label}</title>}
|
|
91
|
-
</rect>
|
|
92
99
|
<rect
|
|
93
100
|
x={x}
|
|
94
|
-
y={barY
|
|
95
|
-
width={
|
|
96
|
-
height={
|
|
97
|
-
fill=
|
|
98
|
-
fillOpacity={isLast ? 1
|
|
99
|
-
|
|
101
|
+
y={barY}
|
|
102
|
+
width={barWidth}
|
|
103
|
+
height={barHeight}
|
|
104
|
+
fill={dense ? color : `url(#${gradId})`}
|
|
105
|
+
fillOpacity={dense ? (isLast ? 1 : 0.75) : undefined}
|
|
106
|
+
>
|
|
107
|
+
{entry.label !== undefined && <title>{entry.label}</title>}
|
|
108
|
+
</rect>
|
|
109
|
+
{!dense && (
|
|
110
|
+
<rect
|
|
111
|
+
x={x}
|
|
112
|
+
y={barY - tickHeight}
|
|
113
|
+
width={barWidth}
|
|
114
|
+
height={tickHeight}
|
|
115
|
+
fill="var(--color-foreground)"
|
|
116
|
+
fillOpacity={isLast ? 1.0 : 0.7}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
100
119
|
</g>
|
|
101
120
|
);
|
|
102
121
|
})}
|