@cosmicdrift/kumiko-renderer-web 0.186.0 → 0.186.2
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 +4 -4
- package/src/__tests__/field-hide-label.test.tsx +44 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +7 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +24 -0
- package/src/primitives/__tests__/money-input.test.tsx +17 -0
- package/src/primitives/embedded-list-input.tsx +219 -209
- package/src/primitives/index.tsx +24 -3
- package/src/primitives/money-input.tsx +20 -2
- package/src/widgets/form-fields.tsx +25 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.186.
|
|
3
|
+
"version": "0.186.2",
|
|
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.186.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.186.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.186.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.186.2",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.186.2",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.186.2",
|
|
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",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defaultPrimitives } from "../primitives";
|
|
3
|
+
import { render } from "./test-utils";
|
|
4
|
+
|
|
5
|
+
const { Field } = defaultPrimitives;
|
|
6
|
+
|
|
7
|
+
describe("DefaultField hideLabel (fw#1870)", () => {
|
|
8
|
+
test("hideLabel kollabiert das Label visuell zu sr-only", () => {
|
|
9
|
+
const view = render(
|
|
10
|
+
<Field id="f1" label="Maps to" hideLabel testId="field">
|
|
11
|
+
<input id="f1" />
|
|
12
|
+
</Field>,
|
|
13
|
+
);
|
|
14
|
+
expect(view.getByText("Maps to").className).toContain("sr-only");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("Label bleibt per htmlFor mit dem Control verknüpft, auch versteckt", () => {
|
|
18
|
+
const view = render(
|
|
19
|
+
<Field id="f1" label="Maps to" hideLabel testId="field">
|
|
20
|
+
<input id="f1" />
|
|
21
|
+
</Field>,
|
|
22
|
+
);
|
|
23
|
+
expect(view.getByLabelText("Maps to")).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("ohne hideLabel bleibt das Label sichtbar (kein sr-only)", () => {
|
|
27
|
+
const view = render(
|
|
28
|
+
<Field id="f1" label="Maps to" testId="field">
|
|
29
|
+
<input id="f1" />
|
|
30
|
+
</Field>,
|
|
31
|
+
);
|
|
32
|
+
expect(view.getByText("Maps to").className).not.toContain("sr-only");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("hideLabel wirkt auch bei layout=inline (BooleanField-Pfad)", () => {
|
|
36
|
+
const view = render(
|
|
37
|
+
<Field id="f1" label="Maps to" layout="inline" hideLabel testId="field">
|
|
38
|
+
<input id="f1" type="checkbox" />
|
|
39
|
+
</Field>,
|
|
40
|
+
);
|
|
41
|
+
expect(view.getByText("Maps to").className).toContain("sr-only");
|
|
42
|
+
expect(view.getByLabelText("Maps to")).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -183,6 +183,13 @@ describe("defaultCellRender", () => {
|
|
|
183
183
|
expect(result).not.toMatch(/[.,]\d\d$/);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
test("money → rehydrated { amount major, amountMinor } uses amountMinor (not major×100 wrong)", () => {
|
|
187
|
+
// rehydrateMoney (fw#1830): amount is major units, amountMinor is cents.
|
|
188
|
+
// formatMoney expects cents — using amount would show 100× too small.
|
|
189
|
+
const result = defaultCellRender({ amount: 119, currency: "EUR", amountMinor: 11900 }, "money");
|
|
190
|
+
expect(result.replace(/[^0-9]/g, "")).toBe("11900");
|
|
191
|
+
});
|
|
192
|
+
|
|
186
193
|
test("money → unerwarteter Value-Shape fällt auf String zurück", () => {
|
|
187
194
|
expect(defaultCellRender(45000, "money")).toBe("45000");
|
|
188
195
|
});
|
|
@@ -107,6 +107,30 @@ describe("EmbeddedListInput — header + rows", () => {
|
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
describe("EmbeddedListInput — desktop/mobile are mutually exclusive mounts (#1854)", () => {
|
|
111
|
+
const rows = [{ description: "Widget A", quantity: 2, amount: 1000 }];
|
|
112
|
+
|
|
113
|
+
test("desktop viewport mounts only the table, not the card layout", () => {
|
|
114
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
115
|
+
expect(screen.getByTestId("lines-desktop")).toBeTruthy();
|
|
116
|
+
expect(screen.queryByTestId("lines-mobile")).toBeNull();
|
|
117
|
+
expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("mobile viewport mounts only the card layout, not the table", () => {
|
|
121
|
+
const originalWidth = window.innerWidth;
|
|
122
|
+
window.innerWidth = 500;
|
|
123
|
+
try {
|
|
124
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
125
|
+
expect(screen.getByTestId("lines-mobile")).toBeTruthy();
|
|
126
|
+
expect(screen.queryByTestId("lines-desktop")).toBeNull();
|
|
127
|
+
expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
|
|
128
|
+
} finally {
|
|
129
|
+
window.innerWidth = originalWidth;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
110
134
|
describe("EmbeddedListInput — row mutation callbacks", () => {
|
|
111
135
|
const rows = [
|
|
112
136
|
{ description: "A", quantity: 1, amount: 100 },
|
|
@@ -104,6 +104,23 @@ describe("MoneyInput — Render (Tier 2)", () => {
|
|
|
104
104
|
expect(value).not.toContain("€");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test("Focus selektiert den gesamten Draft (bewusstes Select-all-on-focus) — sonst hängt ein danach eingefügter Wert an statt sie zu ersetzen (#1856)", () => {
|
|
108
|
+
render(
|
|
109
|
+
<MoneyInput
|
|
110
|
+
id="amt"
|
|
111
|
+
name="amt"
|
|
112
|
+
value={1000}
|
|
113
|
+
onChange={() => {}}
|
|
114
|
+
currency="EUR"
|
|
115
|
+
locale="de-DE"
|
|
116
|
+
/>,
|
|
117
|
+
);
|
|
118
|
+
fireEvent.focus(inputEl());
|
|
119
|
+
const input = inputEl();
|
|
120
|
+
expect(input.selectionStart).toBe(0);
|
|
121
|
+
expect(input.selectionEnd).toBe(input.value.length);
|
|
122
|
+
});
|
|
123
|
+
|
|
107
124
|
test("Blur mit neuem Wert ruft onChange mit Minor-Units (Cents)", () => {
|
|
108
125
|
const onChange = mock((_v: number | undefined) => {});
|
|
109
126
|
render(
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// and reports interaction (cell edits, row add/remove/duplicate/move,
|
|
5
5
|
// paste).
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Only one of the two layouts (table for md+, cards below md) is mounted
|
|
8
|
+
// at a time, picked via useIsMobile — mounting both and toggling with
|
|
9
|
+
// `hidden`/`md:hidden` left two live inputs per cell sharing one DOM id
|
|
10
|
+
// (#1854). useIsMobile reports `false` for the first render regardless of
|
|
11
|
+
// viewport, so the `hidden md:block` / `md:hidden` classes stay on the
|
|
12
|
+
// wrapper divs too: on a phone that first (wrong) render is desktop but
|
|
13
|
+
// CSS-hidden, not a visible flash of the wrong layout.
|
|
10
14
|
|
|
11
15
|
import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
12
16
|
import type {
|
|
@@ -31,6 +35,7 @@ import { Button as UiButton } from "../ui/button";
|
|
|
31
35
|
import { Checkbox } from "../ui/checkbox";
|
|
32
36
|
import { Input as UiInput } from "../ui/input";
|
|
33
37
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
38
|
+
import { useIsMobile } from "../ui/use-mobile";
|
|
34
39
|
import { ComboboxInput } from "./combobox";
|
|
35
40
|
import { DateInput } from "./date-input";
|
|
36
41
|
import { formatMoney, MoneyInput } from "./money-input";
|
|
@@ -369,6 +374,7 @@ export function EmbeddedListInput({
|
|
|
369
374
|
// callers/tests that predate #1839) keep getting the same "EUR" this
|
|
370
375
|
// component always hardcoded.
|
|
371
376
|
const effectiveCurrency = currency ?? "EUR";
|
|
377
|
+
const isMobile = useIsMobile();
|
|
372
378
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
373
379
|
const [pendingFocusCellId, setPendingFocusCellId] = useState<string | undefined>(undefined);
|
|
374
380
|
|
|
@@ -464,120 +470,224 @@ export function EmbeddedListInput({
|
|
|
464
470
|
return (
|
|
465
471
|
<div ref={containerRef} data-testid={testId}>
|
|
466
472
|
{/* ---- Desktop: table ---- */}
|
|
467
|
-
|
|
468
|
-
<div className="
|
|
469
|
-
<
|
|
470
|
-
<
|
|
471
|
-
<
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
{
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
473
|
+
{!isMobile && (
|
|
474
|
+
<div data-testid={testIdFor("desktop")} className="hidden md:block">
|
|
475
|
+
<div className="overflow-hidden rounded-lg border bg-card">
|
|
476
|
+
<Table>
|
|
477
|
+
<TableHeader className="bg-muted">
|
|
478
|
+
<TableRow className="hover:bg-transparent">
|
|
479
|
+
{columns.map((column) => (
|
|
480
|
+
<TableHead
|
|
481
|
+
key={column.field}
|
|
482
|
+
className={cn(columnWidthClass(column.type), columnAlignClass(column.type))}
|
|
483
|
+
>
|
|
484
|
+
{column.label}
|
|
485
|
+
</TableHead>
|
|
486
|
+
))}
|
|
487
|
+
{showControls && <TableHead className="w-px text-right" aria-label="Actions" />}
|
|
488
|
+
</TableRow>
|
|
489
|
+
</TableHeader>
|
|
490
|
+
<TableBody>
|
|
491
|
+
{rows.map((row, rowIndex) => {
|
|
492
|
+
const isLastRow = rowIndex === rows.length - 1;
|
|
493
|
+
const rowIssuesForRow = rowIssues?.[rowIndex];
|
|
494
|
+
return (
|
|
495
|
+
<Fragment
|
|
496
|
+
// 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.
|
|
497
|
+
key={rowIndex}
|
|
498
|
+
>
|
|
499
|
+
<TableRow data-testid={testIdFor(`row-${rowIndex}`)}>
|
|
500
|
+
{columns.map((column, columnIndex) => {
|
|
501
|
+
const isLastCell = isLastRow && columnIndex === columns.length - 1;
|
|
502
|
+
const issues = cellIssues?.[`${rowIndex}.${column.field}`];
|
|
503
|
+
return (
|
|
504
|
+
<TableCell
|
|
505
|
+
key={column.field}
|
|
506
|
+
data-testid={testIdFor(`cell-${rowIndex}-${column.field}`)}
|
|
507
|
+
className={columnWidthClass(column.type)}
|
|
508
|
+
onPaste={
|
|
509
|
+
onPasteCells !== undefined
|
|
510
|
+
? handlePaste(rowIndex, columnIndex)
|
|
511
|
+
: undefined
|
|
512
|
+
}
|
|
513
|
+
onKeyDown={isLastCell ? handleLastCellKeyDown : undefined}
|
|
514
|
+
>
|
|
515
|
+
{renderCellControl({
|
|
516
|
+
cellId: cellId(rowIndex, column.field),
|
|
517
|
+
column,
|
|
518
|
+
value: row[column.field],
|
|
519
|
+
disabled: disabled === true,
|
|
520
|
+
onChange: (value) => onCellChange(rowIndex, column.field, value),
|
|
521
|
+
currency: effectiveCurrency,
|
|
522
|
+
})}
|
|
523
|
+
<IssueMessages
|
|
524
|
+
issues={issues}
|
|
525
|
+
testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
|
|
526
|
+
/>
|
|
527
|
+
</TableCell>
|
|
528
|
+
);
|
|
529
|
+
})}
|
|
530
|
+
{showControls && (
|
|
531
|
+
<TableCell className="text-right">
|
|
532
|
+
<RowActions
|
|
533
|
+
rowIndex={rowIndex}
|
|
534
|
+
rowsLength={rows.length}
|
|
535
|
+
minItems={minItems}
|
|
536
|
+
maxItems={maxItems}
|
|
537
|
+
onDuplicateRow={onDuplicateRow}
|
|
538
|
+
onMoveRow={onMoveRow}
|
|
539
|
+
onRemoveRow={onRemoveRow}
|
|
540
|
+
duplicateLabel={duplicateLabel}
|
|
541
|
+
moveUpLabel={moveUpLabel}
|
|
542
|
+
moveDownLabel={moveDownLabel}
|
|
543
|
+
removeLabel={removeLabel}
|
|
544
|
+
testIdPrefix={testIdFor(`row-${rowIndex}`)}
|
|
545
|
+
/>
|
|
546
|
+
</TableCell>
|
|
547
|
+
)}
|
|
548
|
+
</TableRow>
|
|
549
|
+
{rowIssuesForRow !== undefined && rowIssuesForRow.length > 0 && (
|
|
550
|
+
<TableRow>
|
|
551
|
+
<TableCell colSpan={columns.length + (showControls ? 1 : 0)}>
|
|
516
552
|
<IssueMessages
|
|
517
|
-
issues={
|
|
518
|
-
testId={testIdFor(`
|
|
553
|
+
issues={rowIssuesForRow}
|
|
554
|
+
testId={testIdFor(`row-${rowIndex}-issues`)}
|
|
519
555
|
/>
|
|
520
556
|
</TableCell>
|
|
521
|
-
|
|
522
|
-
})}
|
|
523
|
-
{showControls && (
|
|
524
|
-
<TableCell className="text-right">
|
|
525
|
-
<RowActions
|
|
526
|
-
rowIndex={rowIndex}
|
|
527
|
-
rowsLength={rows.length}
|
|
528
|
-
minItems={minItems}
|
|
529
|
-
maxItems={maxItems}
|
|
530
|
-
onDuplicateRow={onDuplicateRow}
|
|
531
|
-
onMoveRow={onMoveRow}
|
|
532
|
-
onRemoveRow={onRemoveRow}
|
|
533
|
-
duplicateLabel={duplicateLabel}
|
|
534
|
-
moveUpLabel={moveUpLabel}
|
|
535
|
-
moveDownLabel={moveDownLabel}
|
|
536
|
-
removeLabel={removeLabel}
|
|
537
|
-
testIdPrefix={testIdFor(`row-${rowIndex}`)}
|
|
538
|
-
/>
|
|
539
|
-
</TableCell>
|
|
557
|
+
</TableRow>
|
|
540
558
|
)}
|
|
541
|
-
</
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
559
|
+
</Fragment>
|
|
560
|
+
);
|
|
561
|
+
})}
|
|
562
|
+
{showControls && (
|
|
563
|
+
<TableRow className="hover:bg-transparent">
|
|
564
|
+
<TableCell colSpan={columns.length + 1} className="p-2">
|
|
565
|
+
<UiButton
|
|
566
|
+
type="button"
|
|
567
|
+
variant="ghost"
|
|
568
|
+
size="sm"
|
|
569
|
+
onClick={onAddRow}
|
|
570
|
+
disabled={addDisabled}
|
|
571
|
+
data-testid={testIdFor("add")}
|
|
572
|
+
>
|
|
573
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
574
|
+
{addLabel}
|
|
575
|
+
</UiButton>
|
|
576
|
+
</TableCell>
|
|
577
|
+
</TableRow>
|
|
578
|
+
)}
|
|
579
|
+
</TableBody>
|
|
580
|
+
</Table>
|
|
581
|
+
{hasTotals && (
|
|
582
|
+
<div
|
|
583
|
+
data-testid={testIdFor("totals")}
|
|
584
|
+
className="flex flex-wrap items-center justify-end gap-6 border-t bg-muted/30 px-4 py-3 text-sm"
|
|
585
|
+
>
|
|
586
|
+
{totals.map((total) => (
|
|
587
|
+
<div key={total.field} className="flex items-baseline gap-2">
|
|
588
|
+
<span className="text-muted-foreground">{total.label}</span>
|
|
589
|
+
<span className="font-medium tabular-nums">
|
|
590
|
+
{formatTotalValue(total, columns, effectiveCurrency)}
|
|
591
|
+
</span>
|
|
592
|
+
</div>
|
|
593
|
+
))}
|
|
594
|
+
</div>
|
|
595
|
+
)}
|
|
596
|
+
</div>
|
|
597
|
+
<IssueMessages issues={listIssues} testId={testIdFor("list-issues")} />
|
|
598
|
+
</div>
|
|
599
|
+
)}
|
|
600
|
+
|
|
601
|
+
{/* ---- Mobile: cards ---- */}
|
|
602
|
+
{isMobile && (
|
|
603
|
+
<div data-testid={testIdFor("mobile")} className="md:hidden flex flex-col gap-3">
|
|
604
|
+
{rows.map((row, rowIndex) => {
|
|
605
|
+
const isLastRow = rowIndex === rows.length - 1;
|
|
606
|
+
const rowIssuesForRow = rowIssues?.[rowIndex];
|
|
607
|
+
return (
|
|
608
|
+
<div
|
|
609
|
+
// 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.
|
|
610
|
+
key={rowIndex}
|
|
611
|
+
data-testid={testIdFor(`row-${rowIndex}`)}
|
|
612
|
+
className="flex flex-col gap-3 rounded-lg border bg-card p-4"
|
|
613
|
+
>
|
|
614
|
+
{columns.map((column, columnIndex) => {
|
|
615
|
+
const isLastCell = isLastRow && columnIndex === columns.length - 1;
|
|
616
|
+
const issues = cellIssues?.[`${rowIndex}.${column.field}`];
|
|
617
|
+
return (
|
|
618
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: paste/keydown are delegated from the focusable cell control rendered inside, not direct interaction on this wrapper div.
|
|
619
|
+
<div
|
|
620
|
+
key={column.field}
|
|
621
|
+
className="flex flex-col gap-1"
|
|
622
|
+
onPaste={
|
|
623
|
+
onPasteCells !== undefined ? handlePaste(rowIndex, columnIndex) : undefined
|
|
624
|
+
}
|
|
625
|
+
onKeyDown={isLastCell ? handleLastCellKeyDown : undefined}
|
|
565
626
|
>
|
|
566
|
-
<
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
627
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
628
|
+
{column.label}
|
|
629
|
+
</span>
|
|
630
|
+
{renderCellControl({
|
|
631
|
+
cellId: cellId(rowIndex, column.field),
|
|
632
|
+
column,
|
|
633
|
+
value: row[column.field],
|
|
634
|
+
disabled: disabled === true,
|
|
635
|
+
onChange: (value) => onCellChange(rowIndex, column.field, value),
|
|
636
|
+
currency: effectiveCurrency,
|
|
637
|
+
})}
|
|
638
|
+
<IssueMessages
|
|
639
|
+
issues={issues}
|
|
640
|
+
testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
|
|
641
|
+
/>
|
|
642
|
+
</div>
|
|
643
|
+
);
|
|
644
|
+
})}
|
|
645
|
+
{rowIssuesForRow !== undefined && rowIssuesForRow.length > 0 && (
|
|
646
|
+
<IssueMessages
|
|
647
|
+
issues={rowIssuesForRow}
|
|
648
|
+
testId={testIdFor(`row-${rowIndex}-issues`)}
|
|
649
|
+
/>
|
|
650
|
+
)}
|
|
651
|
+
{showControls && (
|
|
652
|
+
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
653
|
+
<RowActions
|
|
654
|
+
rowIndex={rowIndex}
|
|
655
|
+
rowsLength={rows.length}
|
|
656
|
+
minItems={minItems}
|
|
657
|
+
maxItems={maxItems}
|
|
658
|
+
onDuplicateRow={onDuplicateRow}
|
|
659
|
+
onMoveRow={onMoveRow}
|
|
660
|
+
onRemoveRow={onRemoveRow}
|
|
661
|
+
duplicateLabel={duplicateLabel}
|
|
662
|
+
moveUpLabel={moveUpLabel}
|
|
663
|
+
moveDownLabel={moveDownLabel}
|
|
664
|
+
removeLabel={removeLabel}
|
|
665
|
+
testIdPrefix={testIdFor(`row-${rowIndex}`)}
|
|
666
|
+
/>
|
|
667
|
+
</div>
|
|
668
|
+
)}
|
|
669
|
+
</div>
|
|
670
|
+
);
|
|
671
|
+
})}
|
|
672
|
+
{showControls && (
|
|
673
|
+
<UiButton
|
|
674
|
+
type="button"
|
|
675
|
+
variant="outline"
|
|
676
|
+
onClick={onAddRow}
|
|
677
|
+
disabled={addDisabled}
|
|
678
|
+
data-testid={testIdFor("add")}
|
|
679
|
+
>
|
|
680
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
681
|
+
{addLabel}
|
|
682
|
+
</UiButton>
|
|
683
|
+
)}
|
|
574
684
|
{hasTotals && (
|
|
575
685
|
<div
|
|
576
686
|
data-testid={testIdFor("totals")}
|
|
577
|
-
className="flex flex-
|
|
687
|
+
className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-4 text-sm"
|
|
578
688
|
>
|
|
579
689
|
{totals.map((total) => (
|
|
580
|
-
<div key={total.field} className="flex items-
|
|
690
|
+
<div key={total.field} className="flex items-center justify-between">
|
|
581
691
|
<span className="text-muted-foreground">{total.label}</span>
|
|
582
692
|
<span className="font-medium tabular-nums">
|
|
583
693
|
{formatTotalValue(total, columns, effectiveCurrency)}
|
|
@@ -586,109 +696,9 @@ export function EmbeddedListInput({
|
|
|
586
696
|
))}
|
|
587
697
|
</div>
|
|
588
698
|
)}
|
|
699
|
+
<IssueMessages issues={listIssues} testId={testIdFor("list-issues")} />
|
|
589
700
|
</div>
|
|
590
|
-
|
|
591
|
-
</div>
|
|
592
|
-
|
|
593
|
-
{/* ---- Mobile: cards ---- */}
|
|
594
|
-
<div data-testid={testIdFor("mobile")} className="md:hidden flex flex-col gap-3">
|
|
595
|
-
{rows.map((row, rowIndex) => {
|
|
596
|
-
const isLastRow = rowIndex === rows.length - 1;
|
|
597
|
-
const rowIssuesForRow = rowIssues?.[rowIndex];
|
|
598
|
-
return (
|
|
599
|
-
<div
|
|
600
|
-
// 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.
|
|
601
|
-
key={rowIndex}
|
|
602
|
-
data-testid={testIdFor(`row-${rowIndex}`)}
|
|
603
|
-
className="flex flex-col gap-3 rounded-lg border bg-card p-4"
|
|
604
|
-
>
|
|
605
|
-
{columns.map((column, columnIndex) => {
|
|
606
|
-
const isLastCell = isLastRow && columnIndex === columns.length - 1;
|
|
607
|
-
const issues = cellIssues?.[`${rowIndex}.${column.field}`];
|
|
608
|
-
return (
|
|
609
|
-
// biome-ignore lint/a11y/noStaticElementInteractions: paste/keydown are delegated from the focusable cell control rendered inside, not direct interaction on this wrapper div.
|
|
610
|
-
<div
|
|
611
|
-
key={column.field}
|
|
612
|
-
className="flex flex-col gap-1"
|
|
613
|
-
onPaste={
|
|
614
|
-
onPasteCells !== undefined ? handlePaste(rowIndex, columnIndex) : undefined
|
|
615
|
-
}
|
|
616
|
-
onKeyDown={isLastCell ? handleLastCellKeyDown : undefined}
|
|
617
|
-
>
|
|
618
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
619
|
-
{column.label}
|
|
620
|
-
</span>
|
|
621
|
-
{renderCellControl({
|
|
622
|
-
cellId: cellId(rowIndex, column.field),
|
|
623
|
-
column,
|
|
624
|
-
value: row[column.field],
|
|
625
|
-
disabled: disabled === true,
|
|
626
|
-
onChange: (value) => onCellChange(rowIndex, column.field, value),
|
|
627
|
-
currency: effectiveCurrency,
|
|
628
|
-
})}
|
|
629
|
-
<IssueMessages
|
|
630
|
-
issues={issues}
|
|
631
|
-
testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
|
|
632
|
-
/>
|
|
633
|
-
</div>
|
|
634
|
-
);
|
|
635
|
-
})}
|
|
636
|
-
{rowIssuesForRow !== undefined && rowIssuesForRow.length > 0 && (
|
|
637
|
-
<IssueMessages
|
|
638
|
-
issues={rowIssuesForRow}
|
|
639
|
-
testId={testIdFor(`row-${rowIndex}-issues`)}
|
|
640
|
-
/>
|
|
641
|
-
)}
|
|
642
|
-
{showControls && (
|
|
643
|
-
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
|
644
|
-
<RowActions
|
|
645
|
-
rowIndex={rowIndex}
|
|
646
|
-
rowsLength={rows.length}
|
|
647
|
-
minItems={minItems}
|
|
648
|
-
maxItems={maxItems}
|
|
649
|
-
onDuplicateRow={onDuplicateRow}
|
|
650
|
-
onMoveRow={onMoveRow}
|
|
651
|
-
onRemoveRow={onRemoveRow}
|
|
652
|
-
duplicateLabel={duplicateLabel}
|
|
653
|
-
moveUpLabel={moveUpLabel}
|
|
654
|
-
moveDownLabel={moveDownLabel}
|
|
655
|
-
removeLabel={removeLabel}
|
|
656
|
-
testIdPrefix={testIdFor(`row-${rowIndex}`)}
|
|
657
|
-
/>
|
|
658
|
-
</div>
|
|
659
|
-
)}
|
|
660
|
-
</div>
|
|
661
|
-
);
|
|
662
|
-
})}
|
|
663
|
-
{showControls && (
|
|
664
|
-
<UiButton
|
|
665
|
-
type="button"
|
|
666
|
-
variant="outline"
|
|
667
|
-
onClick={onAddRow}
|
|
668
|
-
disabled={addDisabled}
|
|
669
|
-
data-testid={testIdFor("add")}
|
|
670
|
-
>
|
|
671
|
-
<Plus className="size-4" aria-hidden="true" />
|
|
672
|
-
{addLabel}
|
|
673
|
-
</UiButton>
|
|
674
|
-
)}
|
|
675
|
-
{hasTotals && (
|
|
676
|
-
<div
|
|
677
|
-
data-testid={testIdFor("totals")}
|
|
678
|
-
className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-4 text-sm"
|
|
679
|
-
>
|
|
680
|
-
{totals.map((total) => (
|
|
681
|
-
<div key={total.field} className="flex items-center justify-between">
|
|
682
|
-
<span className="text-muted-foreground">{total.label}</span>
|
|
683
|
-
<span className="font-medium tabular-nums">
|
|
684
|
-
{formatTotalValue(total, columns, effectiveCurrency)}
|
|
685
|
-
</span>
|
|
686
|
-
</div>
|
|
687
|
-
))}
|
|
688
|
-
</div>
|
|
689
|
-
)}
|
|
690
|
-
<IssueMessages issues={listIssues} testId={testIdFor("list-issues")} />
|
|
691
|
-
</div>
|
|
701
|
+
)}
|
|
692
702
|
</div>
|
|
693
703
|
);
|
|
694
704
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -180,10 +180,12 @@ function DefaultBanner({
|
|
|
180
180
|
actions,
|
|
181
181
|
padded,
|
|
182
182
|
testId,
|
|
183
|
+
id,
|
|
183
184
|
}: BannerProps): ReactNode {
|
|
184
185
|
const isError = variant === "error";
|
|
185
186
|
const banner = (
|
|
186
187
|
<div
|
|
188
|
+
id={id}
|
|
187
189
|
data-testid={testId}
|
|
188
190
|
role={isError ? "alert" : undefined}
|
|
189
191
|
data-variant={variant}
|
|
@@ -215,12 +217,19 @@ function DefaultField({
|
|
|
215
217
|
fieldAppendix,
|
|
216
218
|
children,
|
|
217
219
|
layout,
|
|
220
|
+
hideLabel,
|
|
218
221
|
testId,
|
|
219
222
|
}: FieldProps): ReactNode {
|
|
220
223
|
const t = useTranslation();
|
|
221
224
|
const hasError = issues !== undefined && issues.length > 0;
|
|
222
225
|
const labelEl = (
|
|
223
|
-
<UiLabel
|
|
226
|
+
<UiLabel
|
|
227
|
+
htmlFor={id}
|
|
228
|
+
className={cn(
|
|
229
|
+
hasError ? "text-destructive" : "text-foreground",
|
|
230
|
+
hideLabel === true && "sr-only",
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
224
233
|
{label}
|
|
225
234
|
{required === true && <span className="ml-0.5 text-destructive">*</span>}
|
|
226
235
|
</UiLabel>
|
|
@@ -1348,7 +1357,14 @@ export function isComponentRendererRef(renderer: unknown): { readonly name: stri
|
|
|
1348
1357
|
// applyFormatSpec re-exported from headless (platform-agnostic).
|
|
1349
1358
|
export { applyFormatSpec };
|
|
1350
1359
|
|
|
1351
|
-
|
|
1360
|
+
type MoneyCellValue = {
|
|
1361
|
+
amount: number;
|
|
1362
|
+
currency: string;
|
|
1363
|
+
/** Exact integer cents from rehydrateMoney (fw#1830); preferred for display. */
|
|
1364
|
+
amountMinor?: number;
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
function isMoneyValue(value: unknown): value is MoneyCellValue {
|
|
1352
1368
|
return (
|
|
1353
1369
|
typeof value === "object" &&
|
|
1354
1370
|
value !== null &&
|
|
@@ -1383,7 +1399,12 @@ export function defaultCellRender(
|
|
|
1383
1399
|
// falls back to guessLocale() (navigator.language), so the same amount
|
|
1384
1400
|
// can render differently in a table vs. a form on the same screen.
|
|
1385
1401
|
// Mid-term: pass the app locale down here too, analogous to render-field.
|
|
1386
|
-
|
|
1402
|
+
//
|
|
1403
|
+
// formatMoney expects minor units. rehydrateMoney returns amount in MAJOR
|
|
1404
|
+
// units plus amountMinor (cents). Prefer amountMinor when present; legacy
|
|
1405
|
+
// shapes without it still carry cents in `amount`.
|
|
1406
|
+
const minor = typeof value.amountMinor === "number" ? value.amountMinor : value.amount;
|
|
1407
|
+
return formatMoney(minor, value.currency);
|
|
1387
1408
|
}
|
|
1388
1409
|
if (type === "select") {
|
|
1389
1410
|
const raw = String(value);
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Cent-genaue Steps will tippt halt im Focus-Modus.
|
|
16
16
|
|
|
17
17
|
import { Minus, Plus } from "lucide-react";
|
|
18
|
-
import { type
|
|
18
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
19
19
|
import { cn } from "../lib/cn";
|
|
20
20
|
|
|
21
21
|
export type MoneyInputProps = {
|
|
@@ -62,6 +62,7 @@ export function MoneyInput({
|
|
|
62
62
|
// Raw-Edit-Buffer während Focus. Sonst würde jeder Tipp-Step durch
|
|
63
63
|
// Math.round → format-Roundtrip jagen und der Cursor würde springen.
|
|
64
64
|
const [draft, setDraft] = useState<string>("");
|
|
65
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
65
66
|
|
|
66
67
|
const major = value === "" ? null : value / factor;
|
|
67
68
|
const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
|
|
@@ -79,11 +80,27 @@ export function MoneyInput({
|
|
|
79
80
|
|
|
80
81
|
const editable = focused ? draft : toEditable(major);
|
|
81
82
|
|
|
82
|
-
const handleFocus = (
|
|
83
|
+
const handleFocus = (): void => {
|
|
83
84
|
setDraft(toEditable(major));
|
|
84
85
|
setFocused(true);
|
|
85
86
|
};
|
|
86
87
|
|
|
88
|
+
// Select-all-on-focus: deliberate, not just a Playwright accommodation.
|
|
89
|
+
// Focus always swaps the displayed value from the formatted string
|
|
90
|
+
// ("1.234,56 €") to the raw editable one ("1234,56"). Measured in a real
|
|
91
|
+
// browser: on this kind of value swap, the browser collapses the cursor
|
|
92
|
+
// to the *end* of the new value rather than preserving position — so
|
|
93
|
+
// without an explicit re-select, typing right after focus appends
|
|
94
|
+
// instead of replacing. That's what silently corrupted values set via
|
|
95
|
+
// Playwright's `.fill()` (framework#1856). It also matches standard
|
|
96
|
+
// money-input UX (immediate overtype on click), and sidesteps mapping a
|
|
97
|
+
// click position from the formatted view to the editable one, which has
|
|
98
|
+
// no well-defined equivalent once separators and the currency symbol
|
|
99
|
+
// are stripped.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (focused) inputRef.current?.select();
|
|
102
|
+
}, [focused]);
|
|
103
|
+
|
|
87
104
|
const handleBlur = (): void => {
|
|
88
105
|
setFocused(false);
|
|
89
106
|
if (draft.trim() === "") {
|
|
@@ -103,6 +120,7 @@ export function MoneyInput({
|
|
|
103
120
|
return (
|
|
104
121
|
<div className="relative w-full">
|
|
105
122
|
<input
|
|
123
|
+
ref={inputRef}
|
|
106
124
|
id={id}
|
|
107
125
|
name={name}
|
|
108
126
|
type="text"
|
|
@@ -9,6 +9,7 @@ export interface NumberFieldProps {
|
|
|
9
9
|
readonly onChange: (v: number | undefined) => void;
|
|
10
10
|
readonly required?: boolean;
|
|
11
11
|
readonly disabled?: boolean;
|
|
12
|
+
readonly hideLabel?: boolean;
|
|
12
13
|
readonly testId?: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -24,11 +25,12 @@ export function NumberField({
|
|
|
24
25
|
onChange,
|
|
25
26
|
required,
|
|
26
27
|
disabled,
|
|
28
|
+
hideLabel,
|
|
27
29
|
testId,
|
|
28
30
|
}: NumberFieldProps): ReactNode {
|
|
29
31
|
const { Field, Input } = usePrimitives();
|
|
30
32
|
return (
|
|
31
|
-
<Field id={id} label={label} required={required} testId={testId}>
|
|
33
|
+
<Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
|
|
32
34
|
<Input
|
|
33
35
|
kind="number"
|
|
34
36
|
id={id}
|
|
@@ -54,6 +56,7 @@ interface FieldBase {
|
|
|
54
56
|
readonly name: string;
|
|
55
57
|
readonly required?: boolean;
|
|
56
58
|
readonly disabled?: boolean;
|
|
59
|
+
readonly hideLabel?: boolean;
|
|
57
60
|
readonly testId?: string;
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -75,11 +78,12 @@ export function TextField({
|
|
|
75
78
|
disabled,
|
|
76
79
|
placeholder,
|
|
77
80
|
autoComplete,
|
|
81
|
+
hideLabel,
|
|
78
82
|
testId,
|
|
79
83
|
}: TextFieldProps): ReactNode {
|
|
80
84
|
const { Field, Input } = usePrimitives();
|
|
81
85
|
return (
|
|
82
|
-
<Field id={id} label={label} required={required} testId={testId}>
|
|
86
|
+
<Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
|
|
83
87
|
<Input
|
|
84
88
|
kind="text"
|
|
85
89
|
id={id}
|
|
@@ -113,11 +117,12 @@ export function SelectField({
|
|
|
113
117
|
options,
|
|
114
118
|
required,
|
|
115
119
|
disabled,
|
|
120
|
+
hideLabel,
|
|
116
121
|
testId,
|
|
117
122
|
}: SelectFieldProps): ReactNode {
|
|
118
123
|
const { Field, Input } = usePrimitives();
|
|
119
124
|
return (
|
|
120
|
-
<Field id={id} label={label} required={required} testId={testId}>
|
|
125
|
+
<Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
|
|
121
126
|
<Input
|
|
122
127
|
kind="select"
|
|
123
128
|
id={id}
|
|
@@ -150,11 +155,12 @@ export function DateField({
|
|
|
150
155
|
max,
|
|
151
156
|
required,
|
|
152
157
|
disabled,
|
|
158
|
+
hideLabel,
|
|
153
159
|
testId,
|
|
154
160
|
}: DateFieldProps): ReactNode {
|
|
155
161
|
const { Field, Input } = usePrimitives();
|
|
156
162
|
return (
|
|
157
|
-
<Field id={id} label={label} required={required} testId={testId}>
|
|
163
|
+
<Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
|
|
158
164
|
<Input
|
|
159
165
|
kind="date"
|
|
160
166
|
id={id}
|
|
@@ -184,11 +190,19 @@ export function BooleanField({
|
|
|
184
190
|
onChange,
|
|
185
191
|
required,
|
|
186
192
|
disabled,
|
|
193
|
+
hideLabel,
|
|
187
194
|
testId,
|
|
188
195
|
}: BooleanFieldProps): ReactNode {
|
|
189
196
|
const { Field, Input } = usePrimitives();
|
|
190
197
|
return (
|
|
191
|
-
<Field
|
|
198
|
+
<Field
|
|
199
|
+
id={id}
|
|
200
|
+
label={label}
|
|
201
|
+
required={required}
|
|
202
|
+
layout="inline"
|
|
203
|
+
hideLabel={hideLabel}
|
|
204
|
+
testId={testId}
|
|
205
|
+
>
|
|
192
206
|
<Input
|
|
193
207
|
kind="boolean"
|
|
194
208
|
id={id}
|
|
@@ -218,11 +232,12 @@ export function TextareaField({
|
|
|
218
232
|
rows,
|
|
219
233
|
required,
|
|
220
234
|
disabled,
|
|
235
|
+
hideLabel,
|
|
221
236
|
testId,
|
|
222
237
|
}: TextareaFieldProps): ReactNode {
|
|
223
238
|
const { Field, Input } = usePrimitives();
|
|
224
239
|
return (
|
|
225
|
-
<Field id={id} label={label} required={required} testId={testId}>
|
|
240
|
+
<Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
|
|
226
241
|
<Input
|
|
227
242
|
kind="textarea"
|
|
228
243
|
id={id}
|
|
@@ -257,11 +272,12 @@ export function RangeField({
|
|
|
257
272
|
step,
|
|
258
273
|
required,
|
|
259
274
|
disabled,
|
|
275
|
+
hideLabel,
|
|
260
276
|
testId,
|
|
261
277
|
}: RangeFieldProps): ReactNode {
|
|
262
278
|
const { Field, Input } = usePrimitives();
|
|
263
279
|
return (
|
|
264
|
-
<Field id={id} label={label} required={required} testId={testId}>
|
|
280
|
+
<Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
|
|
265
281
|
<Input
|
|
266
282
|
kind="range"
|
|
267
283
|
id={id}
|
|
@@ -304,11 +320,12 @@ export function FileField({
|
|
|
304
320
|
fieldName,
|
|
305
321
|
required,
|
|
306
322
|
disabled,
|
|
323
|
+
hideLabel,
|
|
307
324
|
testId,
|
|
308
325
|
}: FileFieldProps): ReactNode {
|
|
309
326
|
const { Field, Input } = usePrimitives();
|
|
310
327
|
return (
|
|
311
|
-
<Field id={id} label={label} required={required} testId={testId}>
|
|
328
|
+
<Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
|
|
312
329
|
<Input
|
|
313
330
|
kind={variant}
|
|
314
331
|
id={id}
|