@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.
- package/package.json +4 -4
- package/src/__tests__/nav-tree.test.tsx +5 -3
- package/src/index.ts +2 -0
- package/src/layout/nav-tree.tsx +2 -0
- package/src/primitives/__tests__/button.test.tsx +27 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +583 -0
- package/src/primitives/embedded-list-input.tsx +694 -0
- package/src/primitives/index.tsx +11 -5
- package/src/widgets/__tests__/infinity-list.test.tsx +238 -1
- package/src/widgets/__tests__/upload-zone.test.tsx +57 -0
- package/src/widgets/index.ts +1 -0
- package/src/widgets/infinity-list.tsx +63 -5
- package/src/widgets/upload-zone.tsx +158 -0
|
@@ -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
|
+
}
|