@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.186.0",
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.0",
20
- "@cosmicdrift/kumiko-headless": "0.186.0",
21
- "@cosmicdrift/kumiko-renderer": "0.186.0",
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
- // Two parallel layouts (table for md+, cards below md) are mounted at
8
- // the same time and toggled via Tailwind's `hidden`/`md:hidden` cheaper
9
- // than a JS media-query and keeps SSR/hydration output stable.
7
+ // Only one of the two layouts (table for md+, cards below md) is mounted
8
+ // at a time, picked via useIsMobilemounting 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
- <div data-testid={testIdFor("desktop")} className="hidden md:block">
468
- <div className="overflow-hidden rounded-lg border bg-card">
469
- <Table>
470
- <TableHeader className="bg-muted">
471
- <TableRow className="hover:bg-transparent">
472
- {columns.map((column) => (
473
- <TableHead
474
- key={column.field}
475
- className={cn(columnWidthClass(column.type), columnAlignClass(column.type))}
476
- >
477
- {column.label}
478
- </TableHead>
479
- ))}
480
- {showControls && <TableHead className="w-px text-right" aria-label="Actions" />}
481
- </TableRow>
482
- </TableHeader>
483
- <TableBody>
484
- {rows.map((row, rowIndex) => {
485
- const isLastRow = rowIndex === rows.length - 1;
486
- const rowIssuesForRow = rowIssues?.[rowIndex];
487
- return (
488
- <Fragment
489
- // 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.
490
- key={rowIndex}
491
- >
492
- <TableRow data-testid={testIdFor(`row-${rowIndex}`)}>
493
- {columns.map((column, columnIndex) => {
494
- const isLastCell = isLastRow && columnIndex === columns.length - 1;
495
- const issues = cellIssues?.[`${rowIndex}.${column.field}`];
496
- return (
497
- <TableCell
498
- key={column.field}
499
- data-testid={testIdFor(`cell-${rowIndex}-${column.field}`)}
500
- className={columnWidthClass(column.type)}
501
- onPaste={
502
- onPasteCells !== undefined
503
- ? handlePaste(rowIndex, columnIndex)
504
- : undefined
505
- }
506
- onKeyDown={isLastCell ? handleLastCellKeyDown : undefined}
507
- >
508
- {renderCellControl({
509
- cellId: cellId(rowIndex, column.field),
510
- column,
511
- value: row[column.field],
512
- disabled: disabled === true,
513
- onChange: (value) => onCellChange(rowIndex, column.field, value),
514
- currency: effectiveCurrency,
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={issues}
518
- testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
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
- </TableRow>
542
- {rowIssuesForRow !== undefined && rowIssuesForRow.length > 0 && (
543
- <TableRow>
544
- <TableCell colSpan={columns.length + (showControls ? 1 : 0)}>
545
- <IssueMessages
546
- issues={rowIssuesForRow}
547
- testId={testIdFor(`row-${rowIndex}-issues`)}
548
- />
549
- </TableCell>
550
- </TableRow>
551
- )}
552
- </Fragment>
553
- );
554
- })}
555
- {showControls && (
556
- <TableRow className="hover:bg-transparent">
557
- <TableCell colSpan={columns.length + 1} className="p-2">
558
- <UiButton
559
- type="button"
560
- variant="ghost"
561
- size="sm"
562
- onClick={onAddRow}
563
- disabled={addDisabled}
564
- data-testid={testIdFor("add")}
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
- <Plus className="size-4" aria-hidden="true" />
567
- {addLabel}
568
- </UiButton>
569
- </TableCell>
570
- </TableRow>
571
- )}
572
- </TableBody>
573
- </Table>
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-wrap items-center justify-end gap-6 border-t bg-muted/30 px-4 py-3 text-sm"
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-baseline gap-2">
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
- <IssueMessages issues={listIssues} testId={testIdFor("list-issues")} />
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
  }
@@ -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 htmlFor={id} className={hasError ? "text-destructive" : "text-foreground"}>
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
- function isMoneyValue(value: unknown): value is { amount: number; currency: string } {
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
- return formatMoney(value.amount, value.currency);
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 FocusEvent, type ReactNode, useState } from "react";
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 = (_e: FocusEvent<HTMLInputElement>): void => {
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 id={id} label={label} required={required} layout="inline" testId={testId}>
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}