@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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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
|
|
199
|
+
test("text → String-Repräsentation", () => {
|
|
164
200
|
expect(defaultCellRender("hallo", "text")).toBe("hallo");
|
|
165
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
</
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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")}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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/
|
|
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
|