@cosmicdrift/kumiko-renderer-web 0.204.0 → 0.205.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.204.0",
3
+ "version": "0.205.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>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.204.0",
20
- "@cosmicdrift/kumiko-headless": "0.204.0",
21
- "@cosmicdrift/kumiko-renderer": "0.204.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.205.0",
20
+ "@cosmicdrift/kumiko-headless": "0.205.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.205.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -108,6 +108,42 @@ describe("applyFormatSpec", () => {
108
108
  expect(applyFormatSpec({ format: "currency" }, 42)).toBe("42");
109
109
  });
110
110
 
111
+ test("number/decimal: locale-formatiert via Intl.NumberFormat (fw#2160)", () => {
112
+ for (const format of ["number", "decimal"] as const) {
113
+ expect(applyFormatSpec({ format }, 245.5)).toBe(
114
+ new Intl.NumberFormat(undefined).format(245.5),
115
+ );
116
+ expect(applyFormatSpec({ format, locale: "de-DE" }, 245.5)).toBe(
117
+ new Intl.NumberFormat("de-DE").format(245.5),
118
+ );
119
+ }
120
+ });
121
+
122
+ test("bigInt: locale-formatiert (immer Integer auf der Wire — z.number().int().safe())", () => {
123
+ // schema-builder.ts case "bigInt": mode:"number" round-trip, safe up to
124
+ // 2^53 — bigInt fields are a plain JS number on the wire, never a bigint
125
+ // primitive or string, so the same formatNumberCell path applies.
126
+ expect(applyFormatSpec({ format: "bigInt" }, 1234567)).toBe(
127
+ new Intl.NumberFormat(undefined).format(1234567),
128
+ );
129
+ expect(applyFormatSpec({ format: "bigInt", locale: "de-DE" }, 1234567)).toBe(
130
+ new Intl.NumberFormat("de-DE").format(1234567),
131
+ );
132
+ });
133
+
134
+ test("number: nicht-numerischer/NaN/Infinity Value fällt auf String zurück", () => {
135
+ expect(applyFormatSpec({ format: "number" }, "not-a-number")).toBe("not-a-number");
136
+ expect(applyFormatSpec({ format: "number" }, Number.NaN)).toBe("NaN");
137
+ expect(applyFormatSpec({ format: "number" }, Number.POSITIVE_INFINITY)).toBe("Infinity");
138
+ });
139
+
140
+ test("number: ungültiges BCP-47 locale wirft nicht, fällt auf String zurück", () => {
141
+ expect(() =>
142
+ applyFormatSpec({ format: "number", locale: "not-a-locale!!" }, 245.5),
143
+ ).not.toThrow();
144
+ expect(applyFormatSpec({ format: "number", locale: "not-a-locale!!" }, 245.5)).toBe("245.5");
145
+ });
146
+
111
147
  test("priority: 0 → Standard-Dash, Zahl → prefix+value", () => {
112
148
  expect(applyFormatSpec({ format: "priority", prefix: "P" }, 0)).toBe("—");
113
149
  expect(applyFormatSpec({ format: "priority", prefix: "P" }, 3)).toBe("P3");
@@ -160,9 +196,17 @@ describe("defaultCellRender", () => {
160
196
  expect(defaultCellRender("op-x", "select", { "op-x": "Operativ X" })).toBe("Operativ X");
161
197
  });
162
198
 
163
- test("text/number → String-Repräsentation", () => {
199
+ test("text → String-Repräsentation", () => {
164
200
  expect(defaultCellRender("hallo", "text")).toBe("hallo");
165
- expect(defaultCellRender(42, "number")).toBe("42");
201
+ });
202
+
203
+ test("number/decimal → locale-formatiert, kein roher Dezimalpunkt (fw#2160)", () => {
204
+ expect(defaultCellRender(42, "number")).toBe(new Intl.NumberFormat(undefined).format(42));
205
+ expect(defaultCellRender(245.5, "decimal")).toBe(
206
+ new Intl.NumberFormat(undefined).format(245.5),
207
+ );
208
+ // de-DE reference to make the fix concrete: "," instead of "." as the separator.
209
+ expect(new Intl.NumberFormat("de-DE").format(245.5)).toBe("245,5");
166
210
  });
167
211
 
168
212
  test("money → { amount, currency } formatiert, kein [object Object]", () => {
@@ -12,7 +12,7 @@ import {
12
12
  } from "@cosmicdrift/kumiko-renderer";
13
13
  import { fireEvent, render, screen, within } from "@testing-library/react";
14
14
  import { act, type ReactElement, useState } from "react";
15
- import { EmbeddedListInput } from "../embedded-list-input";
15
+ import { computeScrollAffordance, EmbeddedListInput } from "../embedded-list-input";
16
16
 
17
17
  function setViewportWidth(width: number): void {
18
18
  (
@@ -755,3 +755,88 @@ describe("EmbeddedListInput — desktop table width (solon#107)", () => {
755
755
  expect(headers[1]?.className).toContain("min-w-[13rem]");
756
756
  });
757
757
  });
758
+
759
+ // happy-dom performs no layout — scrollWidth/clientWidth/getBoundingClientRect
760
+ // always read 0, so the affordance decision itself (computeScrollAffordance)
761
+ // is the only piece of fw#2159 a unit test can actually exercise; the rest
762
+ // of this describe block covers what's left: that the DOM only exposes one
763
+ // real scroll container, and that a fresh table always opens scrolled left.
764
+ describe("EmbeddedListInput — horizontal scroll (fw#2159)", () => {
765
+ describe("computeScrollAffordance", () => {
766
+ test("no affordance when content fits (scrollWidth <= clientWidth)", () => {
767
+ expect(computeScrollAffordance(0, 500, 500)).toEqual({
768
+ canScrollLeft: false,
769
+ canScrollRight: false,
770
+ });
771
+ });
772
+
773
+ test("scrolled fully left: only the right affordance shows", () => {
774
+ expect(computeScrollAffordance(0, 1200, 700)).toEqual({
775
+ canScrollLeft: false,
776
+ canScrollRight: true,
777
+ });
778
+ });
779
+
780
+ test("scrolled to the middle: both affordances show", () => {
781
+ expect(computeScrollAffordance(250, 1200, 700)).toEqual({
782
+ canScrollLeft: true,
783
+ canScrollRight: true,
784
+ });
785
+ });
786
+
787
+ test("scrolled fully right: only the left affordance shows", () => {
788
+ expect(computeScrollAffordance(500, 1200, 700)).toEqual({
789
+ canScrollLeft: true,
790
+ canScrollRight: false,
791
+ });
792
+ });
793
+
794
+ test("sub-pixel rounding at the right edge (499.7px) does not falsely report more to scroll", () => {
795
+ expect(computeScrollAffordance(499.7, 1200, 700)).toEqual({
796
+ canScrollLeft: true,
797
+ canScrollRight: false,
798
+ });
799
+ });
800
+ });
801
+
802
+ test("the desktop table has exactly one horizontally-scrollable container, and it neutralizes the vendored table wrapper's own overflow", () => {
803
+ const rows = [{ description: "A", quantity: 1, amount: 100 }];
804
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
805
+ const desktop = screen.getByTestId("lines-desktop");
806
+
807
+ const scrollContainers = desktop.querySelectorAll('[data-testid="lines-desktop-scroll"]');
808
+ expect(scrollContainers.length).toBe(1);
809
+ const scrollContainer = scrollContainers[0] as HTMLElement;
810
+ expect(scrollContainer.className).toContain("overflow-x-auto");
811
+ // Guards the fw#2159 trap: without this, ui/table.tsx's own
812
+ // `data-slot=table-container` wrapper (also overflow-x-auto) would
813
+ // absorb the actual scrolling, leaving our ref permanently at
814
+ // scrollLeft=0 and the affordance/reset logic silently dead.
815
+ expect(scrollContainer.className).toContain(
816
+ "[&_[data-slot=table-container]]:overflow-x-visible",
817
+ );
818
+
819
+ const nestedTableContainers = scrollContainer.querySelectorAll('[data-slot="table-container"]');
820
+ expect(nestedTableContainers.length).toBe(1);
821
+ });
822
+
823
+ test("a fresh desktop table always mounts scrolled fully left", () => {
824
+ const rows = [{ description: "A", quantity: 1, amount: 100 }];
825
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
826
+ const scrollContainer = screen.getByTestId("lines-desktop-scroll");
827
+ expect(scrollContainer.scrollLeft).toBe(0);
828
+ });
829
+
830
+ test("a scrolled-right container never carries its position into the next mount (e.g. a reopened dialog)", () => {
831
+ const rows = [{ description: "A", quantity: 1, amount: 100 }];
832
+ const { unmount } = renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
833
+ const firstContainer = screen.getByTestId("lines-desktop-scroll");
834
+ firstContainer.scrollLeft = 200;
835
+ expect(firstContainer.scrollLeft).toBe(200);
836
+ unmount();
837
+
838
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
839
+ const secondContainer = screen.getByTestId("lines-desktop-scroll");
840
+ expect(secondContainer.scrollLeft).toBe(0);
841
+ });
842
+ });
@@ -106,6 +106,29 @@ function columnAlignClass(type: EmbeddedListCellType): string {
106
106
  }
107
107
  }
108
108
 
109
+ type ScrollAffordance = {
110
+ readonly canScrollLeft: boolean;
111
+ readonly canScrollRight: boolean;
112
+ };
113
+
114
+ // Exported (unlike columnWidthClass/columnAlignClass above) because
115
+ // happy-dom performs no layout — scrollWidth/clientWidth/getBoundingClientRect
116
+ // always read 0 in tests, so the only way to cover this decision at all is
117
+ // as a pure function of the three scroll metrics, tested directly.
118
+ // The 1px slack absorbs sub-pixel rounding from browser zoom/fractional
119
+ // scaling, which would otherwise report a spurious sliver of "still
120
+ // scrollable" at a fully-scrolled edge.
121
+ export function computeScrollAffordance(
122
+ scrollLeft: number,
123
+ scrollWidth: number,
124
+ clientWidth: number,
125
+ ): ScrollAffordance {
126
+ return {
127
+ canScrollLeft: scrollLeft > 1,
128
+ canScrollRight: scrollLeft + clientWidth < scrollWidth - 1,
129
+ };
130
+ }
131
+
109
132
  function formatTotalValue(
110
133
  total: EmbeddedListTotal,
111
134
  columns: readonly EmbeddedListColumn[],
@@ -409,6 +432,37 @@ export function EmbeddedListInput({
409
432
  const isMobile = useIsNarrowViewport();
410
433
  const containerRef = useRef<HTMLDivElement>(null);
411
434
  const [pendingFocusCellId, setPendingFocusCellId] = useState<string | undefined>(undefined);
435
+ const [scrollAffordance, setScrollAffordance] = useState<ScrollAffordance>({
436
+ canScrollLeft: false,
437
+ canScrollRight: false,
438
+ });
439
+ // A callback ref (not useRef) so the setup effect below re-runs exactly
440
+ // when the scroll container itself is attached/detached — isMobile flips
441
+ // the whole subtree between mobile cards and this table (see the
442
+ // ponytail note at the top of the file), so a plain useRef wouldn't
443
+ // notice the desktop table remounting with a fresh DOM node.
444
+ const [scrollContainerEl, setScrollContainerEl] = useState<HTMLDivElement | null>(null);
445
+
446
+ // Resetting scrollLeft here is what guarantees the table always opens
447
+ // scrolled fully left (fw#2159): browsers still auto-scroll a focused
448
+ // descendant into view even inside an overflow-x-auto container, but
449
+ // that's now a real, user-drivable scroll instead of an invisible one.
450
+ useEffect(() => {
451
+ if (scrollContainerEl === null) return;
452
+ const el = scrollContainerEl;
453
+ el.scrollLeft = 0;
454
+ const updateAffordance = (): void => {
455
+ setScrollAffordance(computeScrollAffordance(el.scrollLeft, el.scrollWidth, el.clientWidth));
456
+ };
457
+ updateAffordance();
458
+ el.addEventListener("scroll", updateAffordance);
459
+ const resizeObserver = new ResizeObserver(updateAffordance);
460
+ resizeObserver.observe(el);
461
+ return () => {
462
+ el.removeEventListener("scroll", updateAffordance);
463
+ resizeObserver.disconnect();
464
+ };
465
+ }, [scrollContainerEl]);
412
466
 
413
467
  // React 19 batches the setPendingFocusCellId + onAddRow calls from the
414
468
  // same keydown handler into one commit, so the new row already exists
@@ -520,118 +574,153 @@ export function EmbeddedListInput({
520
574
  const hasTotals = totals !== undefined && totals.length > 0;
521
575
 
522
576
  return (
523
- <div ref={containerRef} data-testid={testId}>
577
+ <div ref={containerRef} data-testid={testId} className="min-w-0">
524
578
  {/* ---- Desktop: table ---- */}
525
579
  {!isMobile && (
526
- <div data-testid={testIdFor("desktop")} className="hidden md:block">
527
- <div className="overflow-hidden rounded-lg border bg-card">
528
- {/* w-full on Table's <table> would shrink columns below columnWidthClass; min-w-max keeps declared widths and lets the wrapper scroll instead */}
529
- <Table className="min-w-max">
530
- <TableHeader className="bg-muted">
531
- <TableRow className="hover:bg-transparent">
532
- {columns.map((column) => (
533
- <TableHead
534
- key={column.field}
535
- className={cn(columnWidthClass(column.type), columnAlignClass(column.type))}
536
- >
537
- {column.label}
538
- </TableHead>
539
- ))}
540
- {showControls && <TableHead className="w-px text-right" aria-label="Actions" />}
541
- </TableRow>
542
- </TableHeader>
543
- <TableBody>
544
- {rows.map((row, rowIndex) => {
545
- const isLastRow = rowIndex === rows.length - 1;
546
- const rowIssuesForRow = rowIssues?.[rowIndex];
547
- return (
548
- <Fragment
549
- // biome-ignore lint/suspicious/noArrayIndexKey: rows have no caller-guaranteed stable id; every cell is fully controlled (value+onChange), so reordering doesn't rely on DOM node identity surviving between renders.
550
- key={rowIndex}
551
- >
552
- <TableRow data-testid={testIdFor(`row-${rowIndex}`)}>
553
- {columns.map((column, columnIndex) => {
554
- const isLastCell = isLastRow && columnIndex === lastEditableIndex;
555
- const issues = cellIssues?.[`${rowIndex}.${column.field}`];
556
- return (
557
- <TableCell
558
- key={column.field}
559
- data-testid={testIdFor(`cell-${rowIndex}-${column.field}`)}
560
- className={columnWidthClass(column.type)}
561
- onPaste={
562
- onPasteCells !== undefined
563
- ? handlePaste(rowIndex, columnIndex)
564
- : undefined
565
- }
566
- onKeyDown={isLastCell ? handleLastCellKeyDown : undefined}
567
- >
568
- {renderCellControl({
569
- cellId: cellId(rowIndex, column.field),
570
- column,
571
- value: row[column.field],
572
- disabled: disabled === true,
573
- onChange: (value) => onCellChange(rowIndex, column.field, value),
574
- currency: effectiveCurrency,
575
- })}
576
- <IssueMessages
577
- issues={issues}
578
- testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
579
- constrainWidth
580
- />
581
- </TableCell>
582
- );
583
- })}
584
- {showControls && (
585
- <TableCell className="text-right">
586
- <RowActions
587
- rowIndex={rowIndex}
588
- rowsLength={rows.length}
589
- minItems={minItems}
590
- maxItems={maxItems}
591
- onDuplicateRow={onDuplicateRow}
592
- onMoveRow={onMoveRow}
593
- onRemoveRow={onRemoveRow}
594
- duplicateLabel={duplicateLabel}
595
- moveUpLabel={moveUpLabel}
596
- moveDownLabel={moveDownLabel}
597
- removeLabel={removeLabel}
598
- testIdPrefix={testIdFor(`row-${rowIndex}`)}
599
- />
600
- </TableCell>
601
- )}
602
- </TableRow>
603
- {rowIssuesForRow !== undefined && rowIssuesForRow.length > 0 && (
604
- <TableRow>
605
- <TableCell colSpan={columns.length + (showControls ? 1 : 0)}>
606
- <IssueMessages
607
- issues={rowIssuesForRow}
608
- testId={testIdFor(`row-${rowIndex}-issues`)}
609
- />
610
- </TableCell>
611
- </TableRow>
580
+ <div data-testid={testIdFor("desktop")} className="hidden min-w-0 md:block">
581
+ <div className="min-w-0 overflow-hidden rounded-lg border bg-card">
582
+ <div className="relative min-w-0">
583
+ {/* This div — not ui/table.tsx's own `data-slot=table-container`
584
+ wrapper — is the single source of truth for horizontal
585
+ scroll (fw#2159): a table wider than its container must
586
+ become reachable via a real, visible scrollbar instead of
587
+ silently clipping columns. The nested selector below
588
+ neutralizes the vendored wrapper's own overflow-x-auto so
589
+ scrollLeft/scrollWidth/canScroll* all read from one place —
590
+ two nested auto-scroll containers would otherwise let the
591
+ inner one absorb the scroll and leave this ref/effect
592
+ permanently reading scrollLeft=0. */}
593
+ <div
594
+ ref={setScrollContainerEl}
595
+ data-testid={testIdFor("desktop-scroll")}
596
+ className="overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible"
597
+ >
598
+ <Table className="min-w-max">
599
+ <TableHeader className="bg-muted">
600
+ <TableRow className="hover:bg-transparent">
601
+ {columns.map((column) => (
602
+ <TableHead
603
+ key={column.field}
604
+ className={cn(
605
+ columnWidthClass(column.type),
606
+ columnAlignClass(column.type),
607
+ )}
608
+ >
609
+ {column.label}
610
+ </TableHead>
611
+ ))}
612
+ {showControls && (
613
+ <TableHead className="w-px text-right" aria-label="Actions" />
612
614
  )}
613
- </Fragment>
614
- );
615
- })}
616
- {showControls && (
617
- <TableRow className="hover:bg-transparent">
618
- <TableCell colSpan={columns.length + 1} className="p-2">
619
- <UiButton
620
- type="button"
621
- variant="ghost"
622
- size="sm"
623
- onClick={onAddRow}
624
- disabled={addDisabled}
625
- data-testid={testIdFor("add")}
626
- >
627
- <Plus className="size-4" aria-hidden="true" />
628
- {addLabel}
629
- </UiButton>
630
- </TableCell>
631
- </TableRow>
632
- )}
633
- </TableBody>
634
- </Table>
615
+ </TableRow>
616
+ </TableHeader>
617
+ <TableBody>
618
+ {rows.map((row, rowIndex) => {
619
+ const isLastRow = rowIndex === rows.length - 1;
620
+ const rowIssuesForRow = rowIssues?.[rowIndex];
621
+ return (
622
+ <Fragment
623
+ // biome-ignore lint/suspicious/noArrayIndexKey: rows have no caller-guaranteed stable id; every cell is fully controlled (value+onChange), so reordering doesn't rely on DOM node identity surviving between renders.
624
+ key={rowIndex}
625
+ >
626
+ <TableRow data-testid={testIdFor(`row-${rowIndex}`)}>
627
+ {columns.map((column, columnIndex) => {
628
+ const isLastCell = isLastRow && columnIndex === lastEditableIndex;
629
+ const issues = cellIssues?.[`${rowIndex}.${column.field}`];
630
+ return (
631
+ <TableCell
632
+ key={column.field}
633
+ data-testid={testIdFor(`cell-${rowIndex}-${column.field}`)}
634
+ className={columnWidthClass(column.type)}
635
+ onPaste={
636
+ onPasteCells !== undefined
637
+ ? handlePaste(rowIndex, columnIndex)
638
+ : undefined
639
+ }
640
+ onKeyDown={isLastCell ? handleLastCellKeyDown : undefined}
641
+ >
642
+ {renderCellControl({
643
+ cellId: cellId(rowIndex, column.field),
644
+ column,
645
+ value: row[column.field],
646
+ disabled: disabled === true,
647
+ onChange: (value) =>
648
+ onCellChange(rowIndex, column.field, value),
649
+ currency: effectiveCurrency,
650
+ })}
651
+ <IssueMessages
652
+ issues={issues}
653
+ testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
654
+ constrainWidth
655
+ />
656
+ </TableCell>
657
+ );
658
+ })}
659
+ {showControls && (
660
+ <TableCell className="text-right">
661
+ <RowActions
662
+ rowIndex={rowIndex}
663
+ rowsLength={rows.length}
664
+ minItems={minItems}
665
+ maxItems={maxItems}
666
+ onDuplicateRow={onDuplicateRow}
667
+ onMoveRow={onMoveRow}
668
+ onRemoveRow={onRemoveRow}
669
+ duplicateLabel={duplicateLabel}
670
+ moveUpLabel={moveUpLabel}
671
+ moveDownLabel={moveDownLabel}
672
+ removeLabel={removeLabel}
673
+ testIdPrefix={testIdFor(`row-${rowIndex}`)}
674
+ />
675
+ </TableCell>
676
+ )}
677
+ </TableRow>
678
+ {rowIssuesForRow !== undefined && rowIssuesForRow.length > 0 && (
679
+ <TableRow>
680
+ <TableCell colSpan={columns.length + (showControls ? 1 : 0)}>
681
+ <IssueMessages
682
+ issues={rowIssuesForRow}
683
+ testId={testIdFor(`row-${rowIndex}-issues`)}
684
+ />
685
+ </TableCell>
686
+ </TableRow>
687
+ )}
688
+ </Fragment>
689
+ );
690
+ })}
691
+ {showControls && (
692
+ <TableRow className="hover:bg-transparent">
693
+ <TableCell colSpan={columns.length + 1} className="p-2">
694
+ <UiButton
695
+ type="button"
696
+ variant="ghost"
697
+ size="sm"
698
+ onClick={onAddRow}
699
+ disabled={addDisabled}
700
+ data-testid={testIdFor("add")}
701
+ >
702
+ <Plus className="size-4" aria-hidden="true" />
703
+ {addLabel}
704
+ </UiButton>
705
+ </TableCell>
706
+ </TableRow>
707
+ )}
708
+ </TableBody>
709
+ </Table>
710
+ </div>
711
+ {scrollAffordance.canScrollLeft && (
712
+ <div
713
+ aria-hidden="true"
714
+ className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-card to-transparent"
715
+ />
716
+ )}
717
+ {scrollAffordance.canScrollRight && (
718
+ <div
719
+ aria-hidden="true"
720
+ className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-card to-transparent"
721
+ />
722
+ )}
723
+ </div>
635
724
  {hasTotals && (
636
725
  <div
637
726
  data-testid={testIdFor("totals")}
@@ -1431,9 +1431,10 @@ function isMoneyValue(value: unknown): value is MoneyCellValue {
1431
1431
  //
1432
1432
  // - boolean → ✓ / leer
1433
1433
  // - timestamp/date → locale-formatiert (kein roher ISO-String)
1434
+ // - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
1434
1435
  // - select → human-lesbar (kebab-case → Title Case)
1435
1436
  // - money → { amount, currency } formatted via Intl (not "[object Object]")
1436
- // - text/number/sonst → toString
1437
+ // - text/else → toString
1437
1438
  export function defaultCellRender(
1438
1439
  value: unknown,
1439
1440
  type: string,
@@ -1442,6 +1443,12 @@ export function defaultCellRender(
1442
1443
  if (value === null || value === undefined || value === "") return "";
1443
1444
  if (type === "boolean") return value === true ? "✓" : "";
1444
1445
  if (type === "timestamp" || type === "date") return applyFormatSpec({ format: type }, value);
1446
+ if (type === "number" || type === "decimal" || type === "bigInt") {
1447
+ // No `locale` param here for the same reason as the money branch below:
1448
+ // DataTableCell has no app-locale context to thread through yet.
1449
+ // Intl.NumberFormat(undefined) resolves the runtime's default locale.
1450
+ return applyFormatSpec({ format: type }, value);
1451
+ }
1445
1452
  if (type === "money") {
1446
1453
  if (!isMoneyValue(value)) return String(value);
1447
1454
  // No `locale` param here: DataTableCell has no app-locale context to