@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.
@@ -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: bun and happy-dom can expose different Temporal
37
- // copies, so toBeInstanceOf fails depending on file order. The ISO
38
- // string proves both the type and the no-TZ-conversion property.
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
  }
@@ -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: Readonly<Record<string, typeof Mail>> = {
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)[string] | undefined {
331
- return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon) ? FIELD_ICONS[icon] : undefined;
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-Dropdown für >2 actions. Confirm-Dialog ist hier inline pro
1002
- // Item analog zum Inline-Button-Pfad ein gemeinsamer State-Holder
1003
- // per Action damit das Dropdown nach dem Click zumacht und der Dialog
1004
- // danach öffnet (Radix-Dropdown-Item swallowt den Click sonst).
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") return applyFormatSpec({ format: type }, value);
1456
+ if (type === "timestamp" || type === "date") {
1457
+ return applyFormatSpec({ format: type, locale }, value);
1458
+ }
1449
1459
  if (type === "number" || type === "decimal" || type === "bigInt") {
1450
- // No `locale` param here for the same reason as the money branch below:
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(renderer as { format: string } & Record<string, unknown>, value, t);
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
- return (
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 className={overlayClassName} style={overlayStyle} />
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
- // Matches the card footer convention (primitives/index.tsx cardFooter
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 new Promise((resolve) => setTimeout(resolve, 10));
259
- expect(screen.queryByText("Bo-Treffer")).toBeNull();
260
- expect(screen.getByText("Bob-Treffer")).toBeTruthy();
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", () => {
@@ -23,15 +23,17 @@ export type StatusBarEntry = {
23
23
  readonly label?: string;
24
24
  };
25
25
 
26
- /** Status-Balkenleiste (z.B. 90-Tage-Uptime): variable-height Bars mit
27
- * Gradient-Fade + Tick-Line am Bar-Top; der letzte Eintrag bekommt einen
28
- * „jetzt"-Accent-Stripe. */
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 ${entries.length * (1 + barGap)} ${chartHeight}`}
57
- preserveAspectRatio="none"
58
- className="block h-9 w-full"
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 * (1 + barGap);
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
- <defs>
74
- <linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
75
- <stop offset="0%" stopColor={color} stopOpacity={isLast ? 0.85 : 0.5} />
76
- <stop offset="100%" stopColor={color} stopOpacity={0.05} />
77
- </linearGradient>
78
- </defs>
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 - 0.5}
91
+ x={x - barGap / 2}
82
92
  y={0}
83
- width={2}
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 - tickHeight}
95
- width={1}
96
- height={tickHeight}
97
- fill="var(--color-foreground)"
98
- fillOpacity={isLast ? 1.0 : 0.7}
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
  })}