@cosmicdrift/kumiko-renderer-web 0.183.2 → 0.185.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.
@@ -0,0 +1,694 @@
1
+ // EmbeddedListInput — row/totals table for a createEmbeddedListField
2
+ // (invoice-positions-style) field. Controlled: rows, derived values, and
3
+ // issue groups are computed by the caller; this component only renders
4
+ // and reports interaction (cell edits, row add/remove/duplicate/move,
5
+ // paste).
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.
10
+
11
+ import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
12
+ import type {
13
+ EmbeddedListCellType,
14
+ EmbeddedListColumn,
15
+ EmbeddedListInputProps,
16
+ EmbeddedListTotal,
17
+ } from "@cosmicdrift/kumiko-renderer";
18
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
19
+ import { ArrowDown, ArrowUp, Copy, Plus, Trash2 } from "lucide-react";
20
+ import {
21
+ type ClipboardEvent,
22
+ Fragment,
23
+ type KeyboardEvent,
24
+ type ReactNode,
25
+ useEffect,
26
+ useRef,
27
+ useState,
28
+ } from "react";
29
+ import { cn } from "../lib/cn";
30
+ import { Button as UiButton } from "../ui/button";
31
+ import { Checkbox } from "../ui/checkbox";
32
+ import { Input as UiInput } from "../ui/input";
33
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
34
+ import { ComboboxInput } from "./combobox";
35
+ import { DateInput } from "./date-input";
36
+ import { formatMoney, MoneyInput } from "./money-input";
37
+ import { TimestampInput } from "./timestamp-input";
38
+
39
+ // `input[type=hidden]` excluded — ComboboxInput renders one as a plain
40
+ // name-carrier before its focusable trigger button.
41
+ const FOCUSABLE_SELECTOR = "input:not([type=hidden]), button, [tabindex]";
42
+
43
+ function columnWidthClass(type: EmbeddedListCellType): string {
44
+ switch (type) {
45
+ case "money":
46
+ case "number":
47
+ case "decimal":
48
+ case "date":
49
+ return "w-36";
50
+ // Wider than a bare date — carries a date field plus a time input.
51
+ case "timestamp":
52
+ return "w-44";
53
+ case "boolean":
54
+ return "w-16";
55
+ case "text":
56
+ case "select":
57
+ case "reference":
58
+ return "min-w-[10rem]";
59
+ default: {
60
+ const exhaustiveCheck: never = type;
61
+ return exhaustiveCheck;
62
+ }
63
+ }
64
+ }
65
+
66
+ function columnAlignClass(type: EmbeddedListCellType): string {
67
+ switch (type) {
68
+ case "money":
69
+ case "number":
70
+ case "decimal":
71
+ return "text-right";
72
+ case "boolean":
73
+ return "text-center";
74
+ case "date":
75
+ case "timestamp":
76
+ case "text":
77
+ case "select":
78
+ case "reference":
79
+ return "text-left";
80
+ default: {
81
+ const exhaustiveCheck: never = type;
82
+ return exhaustiveCheck;
83
+ }
84
+ }
85
+ }
86
+
87
+ function formatTotalValue(
88
+ total: EmbeddedListTotal,
89
+ columns: readonly EmbeddedListColumn[],
90
+ currency: string,
91
+ ): string {
92
+ const column = columns.find((c) => c.field === total.field);
93
+ if (column?.type === "money") return formatMoney(total.value, currency);
94
+ return total.value.toLocaleString();
95
+ }
96
+
97
+ // Tab/newline-delimited clipboard text → 2D string grid. Not a regex
98
+ // parse — split on the literal delimiters, trim a trailing `\r` per line
99
+ // (Windows clipboard line endings).
100
+ function parsePasteGrid(text: string): readonly (readonly string[])[] {
101
+ return text.split("\n").map((line) => {
102
+ const withoutCr = line.endsWith("\r") ? line.slice(0, -1) : line;
103
+ return withoutCr.split("\t");
104
+ });
105
+ }
106
+
107
+ function IssueMessages({
108
+ issues,
109
+ testId,
110
+ }: {
111
+ readonly issues: readonly FieldIssue[] | undefined;
112
+ readonly testId?: string;
113
+ }): ReactNode {
114
+ const t = useTranslation();
115
+ if (issues === undefined || issues.length === 0) return null;
116
+ return (
117
+ <div role="alert" data-testid={testId} className="text-xs text-destructive">
118
+ {issues.map((issue) => (
119
+ <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
120
+ ))}
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function renderCellControl({
126
+ cellId,
127
+ column,
128
+ value,
129
+ disabled,
130
+ onChange,
131
+ currency,
132
+ }: {
133
+ readonly cellId: string;
134
+ readonly column: EmbeddedListColumn;
135
+ readonly value: unknown;
136
+ readonly disabled: boolean;
137
+ readonly onChange: (value: unknown) => void;
138
+ readonly currency: string;
139
+ }): ReactNode {
140
+ const isDisabled = disabled || column.derived;
141
+
142
+ switch (column.type) {
143
+ case "text":
144
+ return (
145
+ <UiInput
146
+ data-cell-id={cellId}
147
+ type="text"
148
+ disabled={isDisabled}
149
+ value={typeof value === "string" ? value : ""}
150
+ onChange={(e) => onChange(e.target.value)}
151
+ />
152
+ );
153
+ case "number":
154
+ case "decimal":
155
+ return (
156
+ <UiInput
157
+ data-cell-id={cellId}
158
+ type="number"
159
+ disabled={isDisabled}
160
+ className="text-right tabular-nums"
161
+ value={typeof value === "number" ? value : ""}
162
+ onChange={(e) => {
163
+ const raw = e.target.value;
164
+ onChange(raw === "" ? undefined : Number(raw));
165
+ }}
166
+ />
167
+ );
168
+ case "boolean":
169
+ return (
170
+ <div className="flex justify-center">
171
+ <Checkbox
172
+ data-cell-id={cellId}
173
+ disabled={isDisabled}
174
+ checked={value === true}
175
+ onCheckedChange={(checked) => onChange(checked === true)}
176
+ />
177
+ </div>
178
+ );
179
+ case "date":
180
+ return (
181
+ // ponytail: DateInput has no passthrough props for data-cell-id;
182
+ // wrap it instead — the pendingFocusCellId effect walks into the
183
+ // wrapper for its focusable descendant, so auto-focus still works.
184
+ <div data-cell-id={cellId}>
185
+ <DateInput
186
+ id={cellId}
187
+ name={cellId}
188
+ value={typeof value === "string" ? value : ""}
189
+ onChange={(v) => onChange(v)}
190
+ disabled={isDisabled}
191
+ />
192
+ </div>
193
+ );
194
+ case "timestamp":
195
+ return (
196
+ // ponytail: same wrapper pattern as "date" — TimestampInput has no
197
+ // data-cell-id passthrough either.
198
+ <div data-cell-id={cellId}>
199
+ <TimestampInput
200
+ id={cellId}
201
+ name={cellId}
202
+ value={typeof value === "string" ? value : ""}
203
+ onChange={(v) => onChange(v)}
204
+ disabled={isDisabled}
205
+ />
206
+ </div>
207
+ );
208
+ case "money":
209
+ return (
210
+ <div data-cell-id={cellId}>
211
+ <MoneyInput
212
+ id={cellId}
213
+ name={cellId}
214
+ value={typeof value === "number" ? value : ""}
215
+ onChange={(v) => onChange(v)}
216
+ currency={currency}
217
+ disabled={isDisabled}
218
+ />
219
+ </div>
220
+ );
221
+ case "select": {
222
+ const options = (column.options ?? []).map((opt) => ({
223
+ value: opt,
224
+ label: column.optionLabels?.[opt] ?? opt,
225
+ }));
226
+ return (
227
+ <div data-cell-id={cellId}>
228
+ <ComboboxInput
229
+ id={cellId}
230
+ name={cellId}
231
+ value={typeof value === "string" ? value : ""}
232
+ onChange={(v) => onChange(v)}
233
+ options={options}
234
+ disabled={isDisabled}
235
+ />
236
+ </div>
237
+ );
238
+ }
239
+ case "reference":
240
+ return (
241
+ <div data-cell-id={cellId}>
242
+ <ComboboxInput
243
+ id={cellId}
244
+ name={cellId}
245
+ value={typeof value === "string" ? value : ""}
246
+ onChange={(v) => onChange(v)}
247
+ options={column.referenceOptions ?? []}
248
+ disabled={isDisabled}
249
+ loading={column.referenceLoading}
250
+ />
251
+ </div>
252
+ );
253
+ default: {
254
+ const exhaustiveCheck: never = column.type;
255
+ return exhaustiveCheck;
256
+ }
257
+ }
258
+ }
259
+
260
+ type RowActionsProps = {
261
+ readonly rowIndex: number;
262
+ readonly rowsLength: number;
263
+ readonly minItems: number | undefined;
264
+ readonly maxItems: number | undefined;
265
+ readonly onDuplicateRow: (rowIndex: number) => void;
266
+ readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
267
+ readonly onRemoveRow: (rowIndex: number) => void;
268
+ readonly duplicateLabel: string;
269
+ readonly moveUpLabel: string;
270
+ readonly moveDownLabel: string;
271
+ readonly removeLabel: string;
272
+ readonly testIdPrefix: string | undefined;
273
+ };
274
+
275
+ function RowActions({
276
+ rowIndex,
277
+ rowsLength,
278
+ minItems,
279
+ maxItems,
280
+ onDuplicateRow,
281
+ onMoveRow,
282
+ onRemoveRow,
283
+ duplicateLabel,
284
+ moveUpLabel,
285
+ moveDownLabel,
286
+ removeLabel,
287
+ testIdPrefix,
288
+ }: RowActionsProps): ReactNode {
289
+ const duplicateDisabled = maxItems !== undefined && rowsLength >= maxItems;
290
+ const removeDisabled = rowsLength <= (minItems ?? 0);
291
+ return (
292
+ <div className="inline-flex items-center gap-1">
293
+ <UiButton
294
+ type="button"
295
+ variant="ghost"
296
+ size="icon"
297
+ aria-label={duplicateLabel}
298
+ disabled={duplicateDisabled}
299
+ onClick={() => onDuplicateRow(rowIndex)}
300
+ data-testid={testIdPrefix !== undefined ? `${testIdPrefix}-duplicate` : undefined}
301
+ >
302
+ <Copy className="size-4" aria-hidden="true" />
303
+ </UiButton>
304
+ <UiButton
305
+ type="button"
306
+ variant="ghost"
307
+ size="icon"
308
+ aria-label={moveUpLabel}
309
+ disabled={rowIndex === 0}
310
+ onClick={() => onMoveRow(rowIndex, rowIndex - 1)}
311
+ data-testid={testIdPrefix !== undefined ? `${testIdPrefix}-move-up` : undefined}
312
+ >
313
+ <ArrowUp className="size-4" aria-hidden="true" />
314
+ </UiButton>
315
+ <UiButton
316
+ type="button"
317
+ variant="ghost"
318
+ size="icon"
319
+ aria-label={moveDownLabel}
320
+ disabled={rowIndex === rowsLength - 1}
321
+ onClick={() => onMoveRow(rowIndex, rowIndex + 1)}
322
+ data-testid={testIdPrefix !== undefined ? `${testIdPrefix}-move-down` : undefined}
323
+ >
324
+ <ArrowDown className="size-4" aria-hidden="true" />
325
+ </UiButton>
326
+ <UiButton
327
+ type="button"
328
+ variant="ghost"
329
+ size="icon"
330
+ aria-label={removeLabel}
331
+ disabled={removeDisabled}
332
+ onClick={() => onRemoveRow(rowIndex)}
333
+ data-testid={testIdPrefix !== undefined ? `${testIdPrefix}-remove` : undefined}
334
+ >
335
+ <Trash2 className="size-4" aria-hidden="true" />
336
+ </UiButton>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ export function EmbeddedListInput({
342
+ id,
343
+ columns,
344
+ rows,
345
+ totals,
346
+ currency,
347
+ disabled,
348
+ minItems,
349
+ maxItems,
350
+ listIssues,
351
+ rowIssues,
352
+ cellIssues,
353
+ onCellChange,
354
+ onAddRow,
355
+ onRemoveRow,
356
+ onDuplicateRow,
357
+ onMoveRow,
358
+ onPasteCells,
359
+ addLabel,
360
+ removeLabel,
361
+ duplicateLabel,
362
+ moveUpLabel,
363
+ moveDownLabel,
364
+ emptyLabel,
365
+ emptyCtaLabel,
366
+ testId,
367
+ }: EmbeddedListInputProps): ReactNode {
368
+ // Callers that don't wire up the field-currency plumbing (or direct
369
+ // callers/tests that predate #1839) keep getting the same "EUR" this
370
+ // component always hardcoded.
371
+ const effectiveCurrency = currency ?? "EUR";
372
+ const containerRef = useRef<HTMLDivElement>(null);
373
+ const [pendingFocusCellId, setPendingFocusCellId] = useState<string | undefined>(undefined);
374
+
375
+ // React 19 batches the setPendingFocusCellId + onAddRow calls from the
376
+ // same keydown handler into one commit, so the new row already exists
377
+ // by the time this effect runs — no need to depend on rows.length too.
378
+ useEffect(() => {
379
+ if (pendingFocusCellId === undefined) return;
380
+ // ponytail: capability-scoped DOM query, not a global — degrades to
381
+ // "no auto-focus" on a hypothetical native impl instead of crashing.
382
+ // `data-cell-id` sits on the focusable control itself for text/boolean
383
+ // cells, but on a non-focusable wrapper `<div>` for date/money/select/
384
+ // reference/timestamp cells (see renderCellControl) — walk into the
385
+ // wrapper for its focusable descendant instead of calling .focus() on
386
+ // a div. `input[type=hidden]` excluded: ComboboxInput (select/reference)
387
+ // renders a hidden name-carrier input before its trigger button — it
388
+ // would otherwise win the "first match in document order" query
389
+ // without ever actually receiving focus.
390
+ const matched = containerRef.current?.querySelector<HTMLElement>(
391
+ `[data-cell-id="${pendingFocusCellId}"]`,
392
+ );
393
+ const target = matched?.matches(FOCUSABLE_SELECTOR)
394
+ ? matched
395
+ : (matched?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? matched);
396
+ target?.focus();
397
+ setPendingFocusCellId(undefined);
398
+ }, [pendingFocusCellId]);
399
+
400
+ const cellId = (rowIndex: number, field: string): string => `${id}-${rowIndex}-${field}`;
401
+
402
+ const showControls = disabled !== true;
403
+ const addDisabled = maxItems !== undefined && rows.length >= maxItems;
404
+
405
+ const handlePaste = (rowIndex: number, columnIndex: number) => {
406
+ return (event: ClipboardEvent<HTMLElement>): void => {
407
+ if (onPasteCells === undefined) return;
408
+ const text = event.clipboardData?.getData("text") ?? "";
409
+ const grid = parsePasteGrid(text);
410
+ const isMultiCell = grid.length > 1 || (grid[0]?.length ?? 0) > 1;
411
+ if (!isMultiCell) return;
412
+ event.preventDefault();
413
+ onPasteCells(rowIndex, columnIndex, grid);
414
+ };
415
+ };
416
+
417
+ const handleLastCellKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
418
+ if (disabled === true) return;
419
+ const isTabForward = event.key === "Tab" && !event.shiftKey;
420
+ const isEnter = event.key === "Enter";
421
+ if (!isTabForward && !isEnter) return;
422
+ if (maxItems !== undefined && rows.length >= maxItems) return;
423
+ // Enter bubbles up from the cell control through this TableCell — an
424
+ // ancestor of any <form> the caller wraps the whole field in.
425
+ // preventDefault() during the bubble phase still stops that form's
426
+ // default submit, same as it stops Tab's default focus-move below.
427
+ event.preventDefault();
428
+ const firstColumn = columns[0];
429
+ if (firstColumn !== undefined) {
430
+ setPendingFocusCellId(cellId(rows.length, firstColumn.field));
431
+ }
432
+ onAddRow();
433
+ };
434
+
435
+ const testIdFor = (suffix: string): string | undefined =>
436
+ testId !== undefined ? `${testId}-${suffix}` : undefined;
437
+
438
+ if (rows.length === 0) {
439
+ return (
440
+ <div
441
+ ref={containerRef}
442
+ data-testid={testId !== undefined ? `${testId}-empty` : "embedded-list-empty"}
443
+ className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground"
444
+ >
445
+ <span>{emptyLabel}</span>
446
+ {showControls && (
447
+ <UiButton
448
+ type="button"
449
+ variant="outline"
450
+ size="sm"
451
+ onClick={onAddRow}
452
+ data-testid={testId !== undefined ? `${testId}-empty-add` : "embedded-list-empty-add"}
453
+ >
454
+ <Plus className="size-4" aria-hidden="true" />
455
+ {emptyCtaLabel}
456
+ </UiButton>
457
+ )}
458
+ </div>
459
+ );
460
+ }
461
+
462
+ const hasTotals = totals !== undefined && totals.length > 0;
463
+
464
+ return (
465
+ <div ref={containerRef} data-testid={testId}>
466
+ {/* ---- 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
+ })}
516
+ <IssueMessages
517
+ issues={issues}
518
+ testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
519
+ />
520
+ </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>
540
+ )}
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")}
565
+ >
566
+ <Plus className="size-4" aria-hidden="true" />
567
+ {addLabel}
568
+ </UiButton>
569
+ </TableCell>
570
+ </TableRow>
571
+ )}
572
+ </TableBody>
573
+ </Table>
574
+ {hasTotals && (
575
+ <div
576
+ 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"
578
+ >
579
+ {totals.map((total) => (
580
+ <div key={total.field} className="flex items-baseline gap-2">
581
+ <span className="text-muted-foreground">{total.label}</span>
582
+ <span className="font-medium tabular-nums">
583
+ {formatTotalValue(total, columns, effectiveCurrency)}
584
+ </span>
585
+ </div>
586
+ ))}
587
+ </div>
588
+ )}
589
+ </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>
692
+ </div>
693
+ );
694
+ }