@viliha/vui-ui 1.0.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,1506 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ CodeIcon as Code,
6
+ DownloadIcon as Download,
7
+ FileTextIcon as FileText,
8
+ ReaderIcon as Reader,
9
+ TableIcon as SheetIcon,
10
+ UploadIcon as Upload,
11
+ ArrowDownIcon as ArrowDown,
12
+ ArrowTopRightIcon as ArrowUpRight,
13
+ ArrowUpIcon as ArrowUp,
14
+ CaretSortIcon as ArrowUpDown,
15
+ CheckIcon as Check,
16
+ ChevronLeftIcon as ChevronLeft,
17
+ ChevronRightIcon as ChevronRight,
18
+ CircleIcon as Circle,
19
+ CopyIcon as Copy,
20
+ CopyIcon as CopyPlus,
21
+ Cross2Icon as X,
22
+ DotsHorizontalIcon as MoreHorizontal,
23
+ DragHandleDots2Icon as GripVertical,
24
+ EyeOpenIcon as Eye,
25
+ MagnifyingGlassIcon as Search,
26
+ MixerHorizontalIcon as ListFilter,
27
+ MixerHorizontalIcon as SlidersHorizontal,
28
+ Pencil1Icon as Pencil,
29
+ PlusIcon as Plus,
30
+ RowsIcon as Rows3,
31
+ TrashIcon as Trash2,
32
+ } from "@radix-ui/react-icons";
33
+
34
+ import { cn } from "./utils";
35
+ import { Button } from "./button";
36
+ import { Checkbox } from "./checkbox";
37
+ import { Input } from "./input";
38
+ import {
39
+ Table,
40
+ TableBody,
41
+ TableCell,
42
+ TableHead,
43
+ TableHeader,
44
+ TableRow,
45
+ } from "./table";
46
+ import { Dropdown, DropdownItem, DropdownLabel } from "./dropdown-menu";
47
+ import { ConfirmDialog } from "./confirm-dialog";
48
+ import {
49
+ downloadFile,
50
+ parseCSV,
51
+ printTable,
52
+ rowsToCSV,
53
+ rowsToTableHTML,
54
+ type IoColumn,
55
+ } from "./table-io";
56
+
57
+ type RowId = string | number;
58
+ type FieldGroup = "General" | "Work" | "Social" | "System";
59
+ const GROUP_ORDER: FieldGroup[] = ["General", "Work", "Social", "System"];
60
+
61
+ /** Fixed (non-resizable) leading/trailing column widths, in px. */
62
+ const CHECKBOX_W = 44;
63
+ const ACTIONS_W = 120;
64
+ const NAME_COL = "__name";
65
+ const NAME_DEFAULT_W = 190;
66
+ const MIN_COL_W = 80;
67
+ const PAGE_SIZES = [10, 25, 50, 100] as const;
68
+ /** Fallback column-header icon so every column title shows an icon. */
69
+ const DEFAULT_FIELD_ICON = Circle;
70
+
71
+ type ColAlign = "left" | "center" | "right";
72
+
73
+ /** Flexbox + text classes per alignment (for the cell content wrapper). */
74
+ const ALIGN_BOX: Record<ColAlign, string> = {
75
+ left: "",
76
+ center: "justify-center text-center",
77
+ right: "justify-end text-right",
78
+ };
79
+ /** Text-align only (for inputs / render wrappers). */
80
+ const ALIGN_TEXT: Record<ColAlign, string> = {
81
+ left: "",
82
+ center: "text-center",
83
+ right: "text-right",
84
+ };
85
+
86
+ /**
87
+ * Auto-align columns from their data: numeric columns and short codes
88
+ * (all values ≤ 4 chars, e.g. "USD", "EN") center; everything else stays left.
89
+ * An explicit `field.align` always wins.
90
+ */
91
+ function computeColumnAligns<T extends { id: RowId }>(
92
+ fields: RecordField<T>[],
93
+ data: T[],
94
+ ): Record<string, ColAlign> {
95
+ const map: Record<string, ColAlign> = {};
96
+ for (const f of fields) {
97
+ if (f.align) {
98
+ map[f.key] = f.align;
99
+ continue;
100
+ }
101
+ const vals = data
102
+ .map((r) => r[f.key])
103
+ .filter((v) => v !== null && v !== undefined && String(v).trim() !== "");
104
+ if (vals.length === 0) {
105
+ map[f.key] = "left";
106
+ continue;
107
+ }
108
+ const allNumeric = vals.every(
109
+ (v) =>
110
+ typeof v === "number" ||
111
+ (typeof v === "string" && !Number.isNaN(Number(v))),
112
+ );
113
+ const allShort = vals.every((v) => String(v).trim().length <= 4);
114
+ map[f.key] = allNumeric || allShort ? "center" : "left";
115
+ }
116
+ return map;
117
+ }
118
+
119
+ function fieldDefaultWidth<T>(field: RecordField<T>): number {
120
+ return field.width ?? (field.align && field.align !== "left" ? 110 : 160);
121
+ }
122
+
123
+ /** Shared icon component type (all Radix icons share this shape). */
124
+ export type IconType = typeof Circle;
125
+
126
+ /** Mandatory-field marker — an asterisk icon (Radix has no asterisk glyph, so
127
+ it's an inline SVG) sized to the label text; no border chip (no width="15"). */
128
+ function RequiredMark() {
129
+ return (
130
+ <svg
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth={2.5}
135
+ strokeLinecap="round"
136
+ className="size-3.5 shrink-0 self-center text-destructive"
137
+ aria-label="required"
138
+ >
139
+ <line x1="12" y1="5" x2="12" y2="19" />
140
+ <line x1="5.5" y1="8.5" x2="18.5" y2="15.5" />
141
+ <line x1="18.5" y1="8.5" x2="5.5" y2="15.5" />
142
+ </svg>
143
+ );
144
+ }
145
+
146
+ export type PageMeta = { title: string; icon?: IconType };
147
+
148
+ const PageChromeContext = React.createContext<{
149
+ titleLeading?: React.ReactNode;
150
+ /** Current page's title/icon, registered by the active view (e.g. RecordView). */
151
+ page: PageMeta | null;
152
+ setPage: (page: PageMeta | null) => void;
153
+ }>({ page: null, setPage: () => {} });
154
+
155
+ /**
156
+ * Shares page chrome across the app shell: a leading node for the header
157
+ * (e.g. a sidebar-expand toggle) plus the current page's title/icon so a global
158
+ * top bar can display it. Wrap the top bar AND the page content with this.
159
+ */
160
+ export function PageChromeProvider({
161
+ titleLeading,
162
+ children,
163
+ }: {
164
+ titleLeading?: React.ReactNode;
165
+ children: React.ReactNode;
166
+ }) {
167
+ const [page, setPage] = React.useState<PageMeta | null>(null);
168
+ return (
169
+ <PageChromeContext.Provider value={{ titleLeading, page, setPage }}>
170
+ {children}
171
+ </PageChromeContext.Provider>
172
+ );
173
+ }
174
+
175
+ /** Read the current page chrome (title/icon, leading node). */
176
+ export function usePageChrome() {
177
+ return React.useContext(PageChromeContext);
178
+ }
179
+
180
+ /** Register the current page's title/icon into the shell (clears on unmount). */
181
+ export function usePageTitle(title: string, icon?: IconType) {
182
+ const { setPage } = React.useContext(PageChromeContext);
183
+ React.useEffect(() => {
184
+ setPage({ title, icon });
185
+ return () => setPage(null);
186
+ // eslint-disable-next-line react-hooks/exhaustive-deps
187
+ }, [title]);
188
+ }
189
+
190
+ export interface RecordField<T> {
191
+ key: Extract<keyof T, string>;
192
+ label: string;
193
+ icon?: IconType;
194
+ editable?: boolean;
195
+ /** Mark the field mandatory — shows a `*` next to its label. */
196
+ required?: boolean;
197
+ /** Column alignment. Omit to auto-align: numbers and short codes (≤ 4 chars)
198
+ * center, everything else stays left. Set explicitly to override. */
199
+ align?: "left" | "center" | "right";
200
+ group?: FieldGroup;
201
+ /** Initial column width in px (user-resizable via the header handle). */
202
+ width?: number;
203
+ /** Show a copy-to-clipboard action on hover (e.g. email, phone). */
204
+ copyable?: boolean;
205
+ /** Show in the detail panel only, not as a table column (e.g. first/last name). */
206
+ hideInTable?: boolean;
207
+ /** Custom, non-editable cell/value renderer. */
208
+ render?: (row: T) => React.ReactNode;
209
+ /** If set, the field becomes a choice field: the selection toolbar offers a
210
+ * "Set {label}" bulk action listing these values. */
211
+ options?: { value: string; label: string }[];
212
+ }
213
+
214
+ interface RecordViewProps<T extends { id: RowId }> {
215
+ title: string;
216
+ singular: string;
217
+ icon?: IconType;
218
+ fields: RecordField<T>[];
219
+ initialData: T[];
220
+ makeEmptyRow: () => T;
221
+ getPrimary: (row: T) => {
222
+ title: string;
223
+ initials: string;
224
+ subtitle?: string;
225
+ };
226
+ }
227
+
228
+ export function RecordView<T extends { id: RowId }>({
229
+ title,
230
+ singular,
231
+ icon: TitleIcon,
232
+ fields,
233
+ initialData,
234
+ makeEmptyRow,
235
+ getPrimary,
236
+ }: RecordViewProps<T>) {
237
+ const { titleLeading } = React.useContext(PageChromeContext);
238
+ // Surface the page title/icon in the app's global top bar.
239
+ usePageTitle(title, TitleIcon);
240
+ const [rows, setRows] = React.useState<T[]>(initialData);
241
+ const [filter, setFilter] = React.useState("");
242
+ const [sort, setSort] = React.useState<{
243
+ key: string;
244
+ dir: "asc" | "desc";
245
+ } | null>(null);
246
+ const [hidden, setHidden] = React.useState<Set<string>>(new Set());
247
+ const [selected, setSelected] = React.useState<Set<RowId>>(new Set());
248
+ const [editing, setEditing] = React.useState<{
249
+ id: RowId;
250
+ key: string;
251
+ } | null>(null);
252
+ const [draft, setDraft] = React.useState("");
253
+ const [activeId, setActiveId] = React.useState<RowId | null>(null);
254
+ // A row created via "add" but not yet saved — Cancel/close removes it.
255
+ const [newRowId, setNewRowId] = React.useState<RowId | null>(null);
256
+ // Row pending delete confirmation.
257
+ const [confirmDeleteId, setConfirmDeleteId] = React.useState<RowId | null>(null);
258
+ const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
259
+ // Whether the detail panel opened read-only (View) or editable (Edit / Add).
260
+ const [panelReadOnly, setPanelReadOnly] = React.useState(false);
261
+ const [page, setPage] = React.useState(1);
262
+ const [pageSize, setPageSize] = React.useState<number>(25);
263
+ const [flashId, setFlashId] = React.useState<RowId | null>(null);
264
+ const [copiedKey, setCopiedKey] = React.useState<string | null>(null);
265
+ const [dragId, setDragId] = React.useState<RowId | null>(null);
266
+ const [dragOverId, setDragOverId] = React.useState<RowId | null>(null);
267
+ const [menu, setMenu] = React.useState<{
268
+ id: RowId;
269
+ x: number;
270
+ y: number;
271
+ } | null>(null);
272
+ // Empty by default: columns auto-size to their header text via CSS (`w-max`).
273
+ // A key is only set once the user drags a column's resize handle.
274
+ const [colWidths, setColWidths] = React.useState<Record<string, number>>({});
275
+
276
+ const inputRef = React.useRef<HTMLInputElement>(null);
277
+ const nextId = React.useRef(1_000_000);
278
+ React.useEffect(() => {
279
+ if (editing) {
280
+ inputRef.current?.focus();
281
+ inputRef.current?.select();
282
+ }
283
+ }, [editing]);
284
+
285
+ React.useEffect(() => {
286
+ if (!menu) return;
287
+ const close = () => setMenu(null);
288
+ const onKey = (e: KeyboardEvent) => {
289
+ if (e.key === "Escape") setMenu(null);
290
+ };
291
+ window.addEventListener("mousedown", close);
292
+ window.addEventListener("scroll", close, true);
293
+ window.addEventListener("resize", close);
294
+ window.addEventListener("keydown", onKey);
295
+ return () => {
296
+ window.removeEventListener("mousedown", close);
297
+ window.removeEventListener("scroll", close, true);
298
+ window.removeEventListener("resize", close);
299
+ window.removeEventListener("keydown", onKey);
300
+ };
301
+ }, [menu]);
302
+
303
+ const tableFields = fields.filter((f) => !f.hideInTable);
304
+ const visibleFields = tableFields.filter((f) => !hidden.has(f.key));
305
+
306
+ const nameWidth = colWidths[NAME_COL] ?? NAME_DEFAULT_W;
307
+ const totalWidth =
308
+ CHECKBOX_W +
309
+ ACTIONS_W +
310
+ nameWidth +
311
+ visibleFields.reduce(
312
+ (sum, f) => sum + (colWidths[f.key] ?? fieldDefaultWidth(f)),
313
+ 0,
314
+ );
315
+
316
+ const resizeHandle = (col: string, label: string) => (
317
+ <button
318
+ type="button"
319
+ aria-label={`Resize ${label} column`}
320
+ title="Drag to resize"
321
+ onMouseDown={(e) => startResize(col, e)}
322
+ onClick={(e) => e.stopPropagation()}
323
+ onKeyDown={(e) => {
324
+ if (e.key === "ArrowLeft") {
325
+ e.preventDefault();
326
+ nudgeColumn(col, -1);
327
+ }
328
+ if (e.key === "ArrowRight") {
329
+ e.preventDefault();
330
+ nudgeColumn(col, 1);
331
+ }
332
+ }}
333
+ className="absolute right-0 top-0 z-10 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40 focus-visible:bg-primary/60 focus-visible:outline-none"
334
+ />
335
+ );
336
+
337
+ const processed = React.useMemo(() => {
338
+ let out = rows;
339
+ const q = filter.trim().toLowerCase();
340
+ if (q) {
341
+ out = out.filter((row) => {
342
+ const primary = getPrimary(row).title.toLowerCase();
343
+ if (primary.includes(q)) return true;
344
+ return fields.some((f) =>
345
+ String(row[f.key] ?? "")
346
+ .toLowerCase()
347
+ .includes(q),
348
+ );
349
+ });
350
+ }
351
+ if (sort) {
352
+ const { key, dir } = sort;
353
+ out = [...out].sort((a, b) => {
354
+ const av = a[key as keyof T];
355
+ const bv = b[key as keyof T];
356
+ let cmp: number;
357
+ if (typeof av === "number" && typeof bv === "number") {
358
+ cmp = av - bv;
359
+ } else {
360
+ cmp = String(av ?? "").localeCompare(String(bv ?? ""));
361
+ }
362
+ return dir === "asc" ? cmp : -cmp;
363
+ });
364
+ }
365
+ return out;
366
+ }, [rows, filter, sort, fields, getPrimary]);
367
+
368
+ const activeRow = rows.find((r) => r.id === activeId) ?? null;
369
+ const deleteTarget =
370
+ confirmDeleteId != null
371
+ ? (rows.find((r) => r.id === confirmDeleteId) ?? null)
372
+ : null;
373
+
374
+ // Pagination (derived; `page` is clamped so it never points past the last page).
375
+ const totalPages = Math.max(1, Math.ceil(processed.length / pageSize));
376
+ const safePage = Math.min(Math.max(1, page), totalPages);
377
+ const rangeStart = processed.length === 0 ? 0 : (safePage - 1) * pageSize + 1;
378
+ const rangeEnd = Math.min(safePage * pageSize, processed.length);
379
+ const paged = processed.slice((safePage - 1) * pageSize, safePage * pageSize);
380
+
381
+ // Reset to the first page when the filter or page size changes.
382
+ React.useEffect(() => {
383
+ setPage(1);
384
+ }, [filter, pageSize]);
385
+
386
+ function startEdit(row: T, key: string) {
387
+ setEditing({ id: row.id, key });
388
+ setDraft(String(row[key as keyof T] ?? ""));
389
+ }
390
+ function commit() {
391
+ if (!editing) return;
392
+ setRows((prev) =>
393
+ prev.map((row) =>
394
+ row.id === editing.id ? { ...row, [editing.key]: draft } : row,
395
+ ),
396
+ );
397
+ setEditing(null);
398
+ }
399
+ function startResize(key: string, e: React.MouseEvent) {
400
+ e.preventDefault();
401
+ e.stopPropagation();
402
+ const startX = e.clientX;
403
+ const startW = colWidths[key] ?? 160;
404
+ const onMove = (ev: MouseEvent) => {
405
+ setColWidths((prev) => ({
406
+ ...prev,
407
+ [key]: Math.max(MIN_COL_W, startW + (ev.clientX - startX)),
408
+ }));
409
+ };
410
+ const onUp = () => {
411
+ document.removeEventListener("mousemove", onMove);
412
+ document.removeEventListener("mouseup", onUp);
413
+ document.body.style.userSelect = "";
414
+ document.body.style.cursor = "";
415
+ };
416
+ document.addEventListener("mousemove", onMove);
417
+ document.addEventListener("mouseup", onUp);
418
+ document.body.style.userSelect = "none";
419
+ document.body.style.cursor = "col-resize";
420
+ }
421
+ function nudgeColumn(key: string, dir: -1 | 1) {
422
+ setColWidths((prev) => ({
423
+ ...prev,
424
+ [key]: Math.max(MIN_COL_W, (prev[key] ?? 160) + dir * 16),
425
+ }));
426
+ }
427
+ function toggleSort(key: string) {
428
+ setSort((prev) =>
429
+ prev?.key === key
430
+ ? { key, dir: prev.dir === "asc" ? "desc" : "asc" }
431
+ : { key, dir: "asc" },
432
+ );
433
+ }
434
+ function toggleHidden(key: string) {
435
+ setHidden((prev) => {
436
+ const next = new Set(prev);
437
+ if (next.has(key)) next.delete(key);
438
+ else next.add(key);
439
+ return next;
440
+ });
441
+ }
442
+ function toggleSelect(id: RowId) {
443
+ setSelected((prev) => {
444
+ const next = new Set(prev);
445
+ if (next.has(id)) next.delete(id);
446
+ else next.add(id);
447
+ return next;
448
+ });
449
+ }
450
+ function toggleSelectAll() {
451
+ setSelected((prev) =>
452
+ prev.size === processed.length
453
+ ? new Set()
454
+ : new Set(processed.map((r) => r.id)),
455
+ );
456
+ }
457
+ /** Bulk-set a choice field on every selected row (keeps the selection). */
458
+ function bulkSetField(key: keyof T, value: string) {
459
+ setRows((prev) =>
460
+ prev.map((r) => (selected.has(r.id) ? ({ ...r, [key]: value } as T) : r)),
461
+ );
462
+ }
463
+ /** Delete every selected row, then clear the selection. */
464
+ function bulkDelete() {
465
+ setRows((prev) => prev.filter((r) => !selected.has(r.id)));
466
+ if (activeId != null && selected.has(activeId)) setActiveId(null);
467
+ setSelected(new Set());
468
+ setBulkDeleteOpen(false);
469
+ }
470
+ function addRow() {
471
+ const row = { ...makeEmptyRow(), id: nextId.current++ };
472
+ // Prepend so the new record is immediately visible at the top…
473
+ setRows((prev) => [row, ...prev]);
474
+ setPage(1);
475
+ setPanelReadOnly(false);
476
+ setActiveId(row.id);
477
+ setNewRowId(row.id);
478
+ }
479
+ /** Open the detail panel read-only (View). */
480
+ function openView(id: RowId) {
481
+ setPanelReadOnly(true);
482
+ setActiveId(id);
483
+ }
484
+ /** Open the detail panel editable (Edit). */
485
+ function openEdit(id: RowId) {
486
+ setPanelReadOnly(false);
487
+ setActiveId(id);
488
+ }
489
+ /** Commit the form's buffered draft back into the table. */
490
+ function saveForm(updated: T) {
491
+ setRows((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
492
+ // Flash the saved row so the change is unmistakable.
493
+ setFlashId(updated.id);
494
+ window.setTimeout(() => {
495
+ setFlashId((current) => (current === updated.id ? null : current));
496
+ }, 1600);
497
+ setNewRowId(null);
498
+ setActiveId(null);
499
+ }
500
+ /** Discard the form; drop the row entirely if it was never saved. */
501
+ function cancelForm() {
502
+ if (activeId != null && activeId === newRowId) {
503
+ setRows((prev) => prev.filter((r) => r.id !== activeId));
504
+ }
505
+ setNewRowId(null);
506
+ setActiveId(null);
507
+ }
508
+
509
+ const importRef = React.useRef<HTMLInputElement>(null);
510
+ const ioColumns: IoColumn[] = fields.map((f) => ({
511
+ key: f.key,
512
+ label: f.label,
513
+ }));
514
+ /** Export the currently filtered/sorted rows in the chosen format. */
515
+ function exportData(format: "csv" | "excel" | "json" | "pdf") {
516
+ const data = processed as Record<string, unknown>[];
517
+ const base = title.toLowerCase().replace(/\s+/g, "-") || "export";
518
+ if (format === "csv")
519
+ downloadFile(`${base}.csv`, rowsToCSV(ioColumns, data), "text/csv;charset=utf-8");
520
+ else if (format === "json")
521
+ downloadFile(`${base}.json`, JSON.stringify(data, null, 2), "application/json");
522
+ else if (format === "excel")
523
+ downloadFile(`${base}.xls`, rowsToTableHTML(ioColumns, data), "application/vnd.ms-excel");
524
+ else printTable(title, rowsToTableHTML(ioColumns, data));
525
+ }
526
+ /** Parse an imported CSV/JSON file and prepend the rows to the table. */
527
+ async function onImportFile(e: React.ChangeEvent<HTMLInputElement>) {
528
+ const file = e.target.files?.[0];
529
+ e.target.value = "";
530
+ if (!file) return;
531
+ const text = await file.text();
532
+ let records: Record<string, unknown>[] = [];
533
+ try {
534
+ if (file.name.toLowerCase().endsWith(".json")) {
535
+ const parsed: unknown = JSON.parse(text);
536
+ records = Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [];
537
+ } else {
538
+ records = parseCSV(text);
539
+ }
540
+ } catch {
541
+ return;
542
+ }
543
+ const byKey = new Map(fields.map((f) => [f.key.toLowerCase(), f.key]));
544
+ const byLabel = new Map(fields.map((f) => [f.label.toLowerCase(), f.key]));
545
+ const imported = records.map((rec) => {
546
+ const row = { ...makeEmptyRow(), id: nextId.current++ } as Record<
547
+ string,
548
+ unknown
549
+ >;
550
+ for (const [k, v] of Object.entries(rec)) {
551
+ const key = byKey.get(k.toLowerCase()) ?? byLabel.get(k.toLowerCase());
552
+ if (key) row[key] = v;
553
+ }
554
+ return row as T;
555
+ });
556
+ if (imported.length) {
557
+ setRows((prev) => [...imported, ...prev]);
558
+ setPage(1);
559
+ }
560
+ }
561
+ function deleteRow(id: RowId) {
562
+ setRows((prev) => prev.filter((row) => row.id !== id));
563
+ setSelected((prev) => {
564
+ if (!prev.has(id)) return prev;
565
+ const next = new Set(prev);
566
+ next.delete(id);
567
+ return next;
568
+ });
569
+ if (activeId === id) setActiveId(null);
570
+ }
571
+ function duplicateRow(id: RowId) {
572
+ const copyId = nextId.current++;
573
+ setRows((prev) => {
574
+ const index = prev.findIndex((row) => row.id === id);
575
+ if (index < 0) return prev;
576
+ const original = prev[index];
577
+ if (!original) return prev;
578
+ const next = [...prev];
579
+ next.splice(index + 1, 0, { ...original, id: copyId } as T);
580
+ return next;
581
+ });
582
+ setActiveId(copyId);
583
+ }
584
+ function reorder(sourceId: RowId, targetId: RowId) {
585
+ if (sourceId === targetId) return;
586
+ // Manual ordering only makes sense without an active sort.
587
+ setSort(null);
588
+ setRows((prev) => {
589
+ const from = prev.findIndex((row) => row.id === sourceId);
590
+ const to = prev.findIndex((row) => row.id === targetId);
591
+ if (from < 0 || to < 0) return prev;
592
+ const next = [...prev];
593
+ const [moved] = next.splice(from, 1);
594
+ if (!moved) return prev;
595
+ next.splice(to, 0, moved);
596
+ return next;
597
+ });
598
+ }
599
+ async function copyValue(key: string, value: string) {
600
+ try {
601
+ await navigator.clipboard.writeText(value);
602
+ setCopiedKey(key);
603
+ window.setTimeout(
604
+ () => setCopiedKey((current) => (current === key ? null : current)),
605
+ 1200,
606
+ );
607
+ } catch {
608
+ // Clipboard unavailable (insecure context / denied) — no-op.
609
+ }
610
+ }
611
+
612
+ const allSelected =
613
+ processed.length > 0 && selected.size === processed.length;
614
+ // Choice fields (with `options`) power the "Set …" bulk actions.
615
+ const bulkFields = fields.filter((f) => f.options && f.options.length > 0);
616
+ // Per-column alignment (auto: numbers + short codes center).
617
+ const columnAligns = React.useMemo(
618
+ () => computeColumnAligns(fields, initialData),
619
+ [fields, initialData],
620
+ );
621
+ const alignOf = (key: string): ColAlign => columnAligns[key] ?? "left";
622
+
623
+ function renderCellValue(row: T, field: RecordField<T>) {
624
+ const isEditing = editing?.id === row.id && editing.key === field.key;
625
+ if (field.render) {
626
+ return (
627
+ <div className={cn("px-3 py-1.5", ALIGN_TEXT[alignOf(field.key)])}>
628
+ {field.render(row)}
629
+ </div>
630
+ );
631
+ }
632
+ if (isEditing) {
633
+ return (
634
+ <input
635
+ ref={inputRef}
636
+ value={draft}
637
+ onChange={(e) => setDraft(e.target.value)}
638
+ onBlur={commit}
639
+ onKeyDown={(e) => {
640
+ if (e.key === "Enter") commit();
641
+ if (e.key === "Escape") setEditing(null);
642
+ }}
643
+ aria-label={`Edit ${field.label}`}
644
+ className={cn(
645
+ "h-8 w-full bg-background px-3 outline-none ring-2 ring-inset ring-ring",
646
+ ALIGN_TEXT[alignOf(field.key)],
647
+ )}
648
+ />
649
+ );
650
+ }
651
+ const value = String(row[field.key] ?? "");
652
+ const cellKey = `${row.id}:${field.key}`;
653
+ const hoverActions =
654
+ (field.editable || (field.copyable && value)) ? (
655
+ <span className="absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/cell:opacity-100 focus-within:opacity-100">
656
+ {field.copyable && value && (
657
+ <button
658
+ type="button"
659
+ onClick={(e) => {
660
+ e.stopPropagation();
661
+ void copyValue(cellKey, value);
662
+ }}
663
+ aria-label={`Copy ${field.label}`}
664
+ title={`Copy ${field.label}`}
665
+ className="grid size-6 place-items-center rounded-sm bg-background text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
666
+ >
667
+ {copiedKey === cellKey ? (
668
+ <Check className="size-3.5 text-emerald-600" />
669
+ ) : (
670
+ <Copy className="size-3.5" />
671
+ )}
672
+ </button>
673
+ )}
674
+ {field.editable && (
675
+ <button
676
+ type="button"
677
+ onClick={(e) => {
678
+ e.stopPropagation();
679
+ startEdit(row, field.key);
680
+ }}
681
+ aria-label={`Edit ${field.label}`}
682
+ title={`Edit ${field.label}`}
683
+ className="grid size-6 place-items-center rounded-sm bg-background text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
684
+ >
685
+ <Pencil className="size-3.5" />
686
+ </button>
687
+ )}
688
+ </span>
689
+ ) : null;
690
+
691
+ if (field.editable) {
692
+ return (
693
+ <div className="group/cell relative flex h-8 w-full items-center">
694
+ <button
695
+ type="button"
696
+ onClick={() => startEdit(row, field.key)}
697
+ className={cn(
698
+ "flex h-8 w-full items-center overflow-hidden px-3 text-left hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
699
+ ALIGN_BOX[alignOf(field.key)],
700
+ )}
701
+ >
702
+ <span className="truncate">
703
+ {value || <span className="text-muted-foreground">—</span>}
704
+ </span>
705
+ </button>
706
+ {hoverActions}
707
+ </div>
708
+ );
709
+ }
710
+ return (
711
+ <div
712
+ className={cn(
713
+ "group/cell relative flex h-8 items-center px-3",
714
+ ALIGN_BOX[alignOf(field.key)],
715
+ )}
716
+ >
717
+ <span className="truncate">{value}</span>
718
+ {hoverActions}
719
+ </div>
720
+ );
721
+ }
722
+
723
+ return (
724
+ <div className="flex h-full flex-col">
725
+ {/* Header — title/icon now live in the global top bar; this row holds the
726
+ per-record actions (add / import / export). */}
727
+ <div className="flex h-12 items-center justify-between border-b border-border px-4">
728
+ <div className="flex items-center gap-2">{titleLeading}</div>
729
+ <div className="flex items-center gap-1.5">
730
+ <Button
731
+ variant="link"
732
+ size="sm"
733
+ onClick={addRow}
734
+ className="px-1 no-underline hover:no-underline"
735
+ >
736
+ <Plus className="size-4 text-emerald-500" />
737
+ <span>{singular}</span>
738
+ </Button>
739
+
740
+ <input
741
+ ref={importRef}
742
+ type="file"
743
+ accept=".csv,.json"
744
+ onChange={onImportFile}
745
+ className="hidden"
746
+ aria-hidden="true"
747
+ />
748
+ <Dropdown
749
+ label="Import"
750
+ icon={<Upload className="size-3.5 text-sky-500" />}
751
+ align="end"
752
+ >
753
+ <DropdownLabel>Import from</DropdownLabel>
754
+ <DropdownItem onSelect={() => importRef.current?.click()}>
755
+ <span className="flex items-center gap-2">
756
+ <FileText className="size-3.5" /> CSV
757
+ </span>
758
+ </DropdownItem>
759
+ <DropdownItem onSelect={() => importRef.current?.click()}>
760
+ <span className="flex items-center gap-2">
761
+ <Code className="size-3.5" /> JSON
762
+ </span>
763
+ </DropdownItem>
764
+ <DropdownItem onSelect={() => importRef.current?.click()}>
765
+ <span className="flex items-center gap-2">
766
+ <SheetIcon className="size-3.5" /> Excel
767
+ </span>
768
+ </DropdownItem>
769
+ </Dropdown>
770
+
771
+ <Dropdown
772
+ label="Export"
773
+ icon={<Download className="size-3.5 text-violet-500" />}
774
+ align="end"
775
+ >
776
+ <DropdownLabel>Export as</DropdownLabel>
777
+ <DropdownItem onSelect={() => exportData("csv")}>
778
+ <span className="flex items-center gap-2">
779
+ <FileText className="size-3.5" /> CSV
780
+ </span>
781
+ </DropdownItem>
782
+ <DropdownItem onSelect={() => exportData("excel")}>
783
+ <span className="flex items-center gap-2">
784
+ <SheetIcon className="size-3.5" /> Excel
785
+ </span>
786
+ </DropdownItem>
787
+ <DropdownItem onSelect={() => exportData("json")}>
788
+ <span className="flex items-center gap-2">
789
+ <Code className="size-3.5" /> JSON
790
+ </span>
791
+ </DropdownItem>
792
+ <DropdownItem onSelect={() => exportData("pdf")}>
793
+ <span className="flex items-center gap-2">
794
+ <Reader className="size-3.5" /> PDF
795
+ </span>
796
+ </DropdownItem>
797
+ </Dropdown>
798
+
799
+ <Dropdown
800
+ label=""
801
+ ariaLabel="More actions"
802
+ icon={<MoreHorizontal className="size-4 text-slate-500" />}
803
+ align="end"
804
+ >
805
+ <DropdownItem onSelect={() => setSelected(new Set())}>
806
+ Clear selection
807
+ </DropdownItem>
808
+ <DropdownItem onSelect={() => setHidden(new Set())}>
809
+ Show all columns
810
+ </DropdownItem>
811
+ </Dropdown>
812
+ </div>
813
+ </div>
814
+
815
+ {/* Content — padded, bordered card (matches the settings-page layout) */}
816
+ <div className="min-h-0 flex-1 overflow-hidden p-4">
817
+ <div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
818
+ {/* Sub-toolbar */}
819
+ <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-1.5">
820
+ <div className="flex items-center gap-2">
821
+ <ListFilter className="size-4 text-muted-foreground" />
822
+ {selected.size > 0 ? (
823
+ <span className="flex items-center gap-2">
824
+ <span className="font-medium">{selected.size} selected</span>
825
+ <button
826
+ type="button"
827
+ onClick={() => setSelected(new Set())}
828
+ className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
829
+ >
830
+ Clear
831
+ </button>
832
+ </span>
833
+ ) : (
834
+ <span className="font-medium">All {title}</span>
835
+ )}
836
+ </div>
837
+ <div className="flex items-center gap-0.5">
838
+ {/* Bulk actions — mirror the Options dropdown; shown only with a selection. */}
839
+ {selected.size > 0 && (
840
+ <Dropdown
841
+ label="Actions"
842
+ icon={<MoreHorizontal className="size-3.5 text-violet-500" />}
843
+ >
844
+ <DropdownLabel>{selected.size} selected</DropdownLabel>
845
+ {bulkFields.map((f) => (
846
+ <React.Fragment key={f.key}>
847
+ <DropdownLabel>Set {f.label}</DropdownLabel>
848
+ {f.options?.map((o) => (
849
+ <DropdownItem
850
+ key={o.value}
851
+ onSelect={() => bulkSetField(f.key, o.value)}
852
+ >
853
+ {o.label}
854
+ </DropdownItem>
855
+ ))}
856
+ </React.Fragment>
857
+ ))}
858
+ <DropdownItem onSelect={() => setBulkDeleteOpen(true)}>
859
+ <span className="flex items-center gap-2 text-destructive">
860
+ <Trash2 className="size-3.5" /> Delete {selected.size} selected
861
+ </span>
862
+ </DropdownItem>
863
+ </Dropdown>
864
+ )}
865
+ <Dropdown label="Filter" icon={<ListFilter className="size-3.5 text-amber-500" />}>
866
+ <DropdownLabel>Filter by keyword</DropdownLabel>
867
+ <div className="p-3">
868
+ <div className="relative">
869
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
870
+ <Input
871
+ value={filter}
872
+ onChange={(e) => setFilter(e.target.value)}
873
+ placeholder="Contains…"
874
+ aria-label="Filter"
875
+ className="h-8 pl-9"
876
+ />
877
+ </div>
878
+ </div>
879
+ </Dropdown>
880
+
881
+ <Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
882
+ <DropdownLabel>Sort by</DropdownLabel>
883
+ {tableFields.map((f) => (
884
+ <DropdownItem
885
+ key={f.key}
886
+ onSelect={() => toggleSort(f.key)}
887
+ icon={
888
+ sort?.key === f.key ? (
889
+ sort.dir === "asc" ? (
890
+ <ArrowUp className="size-3.5" />
891
+ ) : (
892
+ <ArrowDown className="size-3.5" />
893
+ )
894
+ ) : undefined
895
+ }
896
+ >
897
+ {f.label}
898
+ </DropdownItem>
899
+ ))}
900
+ {sort && (
901
+ <DropdownItem onSelect={() => setSort(null)}>
902
+ Clear sort
903
+ </DropdownItem>
904
+ )}
905
+ </Dropdown>
906
+
907
+ <Dropdown
908
+ label="Options"
909
+ icon={<SlidersHorizontal className="size-3.5 text-fuchsia-500" />}
910
+ align="end"
911
+ >
912
+ <DropdownLabel>Visible columns</DropdownLabel>
913
+ {tableFields.map((f) => (
914
+ <DropdownItem
915
+ key={f.key}
916
+ checked={!hidden.has(f.key)}
917
+ onSelect={() => toggleHidden(f.key)}
918
+ >
919
+ {f.label}
920
+ </DropdownItem>
921
+ ))}
922
+ </Dropdown>
923
+
924
+ {/* Pagination */}
925
+ <div className="ml-1 flex items-center gap-1 border-l border-border pl-2 text-muted-foreground">
926
+ <Dropdown
927
+ label={`${pageSize} / page`}
928
+ icon={<Rows3 className="size-3.5 text-teal-500" />}
929
+ align="end"
930
+ >
931
+ <DropdownLabel>Rows per page</DropdownLabel>
932
+ {PAGE_SIZES.map((n) => (
933
+ <DropdownItem
934
+ key={n}
935
+ checked={pageSize === n}
936
+ onSelect={() => setPageSize(n)}
937
+ >
938
+ {n} per page
939
+ </DropdownItem>
940
+ ))}
941
+ </Dropdown>
942
+ <span className="whitespace-nowrap px-1 tabular-nums">
943
+ {rangeStart}–{rangeEnd} of {processed.length}
944
+ </span>
945
+ <button
946
+ type="button"
947
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
948
+ disabled={safePage <= 1}
949
+ aria-label="Previous page"
950
+ className="grid size-7 place-items-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:hover:bg-transparent"
951
+ >
952
+ <ChevronLeft className="size-4" />
953
+ </button>
954
+ <button
955
+ type="button"
956
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
957
+ disabled={safePage >= totalPages}
958
+ aria-label="Next page"
959
+ className="grid size-7 place-items-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:hover:bg-transparent"
960
+ >
961
+ <ChevronRight className="size-4" />
962
+ </button>
963
+ </div>
964
+ </div>
965
+ </div>
966
+
967
+ {/* Table */}
968
+ <div className="min-h-0 flex-1 overflow-auto">
969
+ <Table
970
+ style={{ minWidth: totalWidth, tableLayout: "auto" }}
971
+ className="w-full"
972
+ >
973
+ <TableHeader className="sticky top-0 z-20 bg-background [&_th]:sticky [&_th]:top-0 [&_th]:z-20 [&_th]:bg-background">
974
+ <TableRow className="hover:bg-transparent">
975
+ <TableHead style={{ width: CHECKBOX_W }} className="p-0">
976
+ <div className="flex h-8 items-center pl-4">
977
+ <Checkbox
978
+ checked={allSelected}
979
+ onChange={toggleSelectAll}
980
+ aria-label="Select all"
981
+ />
982
+ </div>
983
+ </TableHead>
984
+ <TableHead className="relative w-max" style={{ width: colWidths[NAME_COL] }}>
985
+ <span className="flex h-8 items-center gap-1.5 whitespace-nowrap">
986
+ {TitleIcon ? (
987
+ <TitleIcon className="size-3.5 shrink-0 text-foreground" />
988
+ ) : (
989
+ <DEFAULT_FIELD_ICON className="size-3.5 shrink-0 text-foreground" />
990
+ )}
991
+ Name
992
+ </span>
993
+ {resizeHandle(NAME_COL, "Name")}
994
+ </TableHead>
995
+ {visibleFields.map((f) => {
996
+ const HeadIcon = f.icon ?? DEFAULT_FIELD_ICON;
997
+ return (
998
+ <TableHead
999
+ key={f.key}
1000
+ className="relative w-max"
1001
+ style={{ width: colWidths[f.key] }}
1002
+ >
1003
+ <button
1004
+ type="button"
1005
+ onClick={() => toggleSort(f.key)}
1006
+ className={cn(
1007
+ "flex h-8 w-full items-center gap-1.5 whitespace-nowrap hover:text-foreground",
1008
+ ALIGN_BOX[alignOf(f.key)],
1009
+ )}
1010
+ >
1011
+ <HeadIcon className="size-3.5 shrink-0 text-foreground" />
1012
+ <span className="flex items-center gap-1 whitespace-nowrap">
1013
+ {f.label}
1014
+ {f.required && <RequiredMark />}
1015
+ </span>
1016
+ {sort?.key === f.key &&
1017
+ (sort.dir === "asc" ? (
1018
+ <ArrowUp className="size-3 shrink-0" />
1019
+ ) : (
1020
+ <ArrowDown className="size-3 shrink-0" />
1021
+ ))}
1022
+ </button>
1023
+ {resizeHandle(f.key, f.label)}
1024
+ </TableHead>
1025
+ );
1026
+ })}
1027
+ <TableHead
1028
+ style={{ width: ACTIONS_W }}
1029
+ className="border-r-0 text-right"
1030
+ >
1031
+ <span className="flex h-8 items-center justify-end whitespace-nowrap pr-2">
1032
+ Actions
1033
+ </span>
1034
+ </TableHead>
1035
+ {/* Filler so row borders reach the right edge of the page. */}
1036
+ <TableHead aria-hidden="true" />
1037
+ </TableRow>
1038
+ </TableHeader>
1039
+ <TableBody>
1040
+ {processed.length ? (
1041
+ paged.map((row) => {
1042
+ const primary = getPrimary(row);
1043
+ return (
1044
+ <TableRow
1045
+ key={row.id}
1046
+ data-active={row.id === activeId}
1047
+ data-flash={row.id === flashId}
1048
+ data-dragover={row.id === dragOverId && dragId !== row.id}
1049
+ onContextMenu={(e) => {
1050
+ e.preventDefault();
1051
+ setMenu({ id: row.id, x: e.clientX, y: e.clientY });
1052
+ }}
1053
+ onDragOver={(e) => {
1054
+ if (dragId === null) return;
1055
+ e.preventDefault();
1056
+ e.dataTransfer.dropEffect = "move";
1057
+ setDragOverId(row.id);
1058
+ }}
1059
+ onDrop={(e) => {
1060
+ if (dragId === null) return;
1061
+ e.preventDefault();
1062
+ reorder(dragId, row.id);
1063
+ setDragId(null);
1064
+ setDragOverId(null);
1065
+ }}
1066
+ className="group data-[active=true]:bg-accent/60 data-[dragover=true]:border-t-2 data-[dragover=true]:border-t-primary data-[flash=true]:bg-primary/10"
1067
+ >
1068
+ <TableCell className="p-0">
1069
+ <div className="relative flex h-8 items-center pl-4">
1070
+ {/* Drag grip — overlaid in the left gutter on hover,
1071
+ so it never shifts the checkbox's alignment. */}
1072
+ <div
1073
+ draggable
1074
+ onDragStart={(e) => {
1075
+ e.dataTransfer.effectAllowed = "move";
1076
+ e.dataTransfer.setData("text/plain", String(row.id));
1077
+ setDragId(row.id);
1078
+ }}
1079
+ onDragEnd={() => {
1080
+ setDragId(null);
1081
+ setDragOverId(null);
1082
+ }}
1083
+ aria-label={`Drag ${primary.title || singular} to reorder`}
1084
+ title="Drag to reorder"
1085
+ className="absolute left-0 top-1/2 flex h-6 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-muted-foreground/50 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 active:cursor-grabbing"
1086
+ >
1087
+ <GripVertical className="size-3.5" />
1088
+ </div>
1089
+ <Checkbox
1090
+ checked={selected.has(row.id)}
1091
+ onChange={() => toggleSelect(row.id)}
1092
+ aria-label={`Select ${primary.title}`}
1093
+ />
1094
+ </div>
1095
+ </TableCell>
1096
+ <TableCell
1097
+ className="p-0"
1098
+ style={{ maxWidth: colWidths[NAME_COL] ?? NAME_DEFAULT_W }}
1099
+ >
1100
+ <button
1101
+ type="button"
1102
+ onClick={() => openView(row.id)}
1103
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-muted/60"
1104
+ >
1105
+ <span className="flex size-5 shrink-0 items-center justify-center rounded bg-muted font-medium text-muted-foreground">
1106
+ {primary.initials}
1107
+ </span>
1108
+ <span className="truncate">
1109
+ {primary.title || "—"}
1110
+ </span>
1111
+ </button>
1112
+ </TableCell>
1113
+ {visibleFields.map((f) => (
1114
+ <TableCell
1115
+ key={f.key}
1116
+ className="p-0"
1117
+ style={{ maxWidth: colWidths[f.key] ?? fieldDefaultWidth(f) }}
1118
+ >
1119
+ {renderCellValue(row, f)}
1120
+ </TableCell>
1121
+ ))}
1122
+ <TableCell
1123
+ className="border-r-0 p-0"
1124
+ style={{ width: ACTIONS_W }}
1125
+ >
1126
+ <div className="flex items-center justify-end gap-0.5 pr-2">
1127
+ <button
1128
+ type="button"
1129
+ onClick={() => openView(row.id)}
1130
+ aria-label={`View ${primary.title || singular}`}
1131
+ title="View"
1132
+ className="grid size-7 cursor-pointer place-items-center rounded-sm hover:bg-muted"
1133
+ >
1134
+ <Eye className="size-4 text-blue-500" />
1135
+ </button>
1136
+ <button
1137
+ type="button"
1138
+ onClick={() => openEdit(row.id)}
1139
+ aria-label={`Edit ${primary.title || singular}`}
1140
+ title="Edit"
1141
+ className="grid size-7 cursor-pointer place-items-center rounded-sm hover:bg-muted"
1142
+ >
1143
+ <Pencil className="size-4 text-amber-500" />
1144
+ </button>
1145
+ <button
1146
+ type="button"
1147
+ onClick={() => setConfirmDeleteId(row.id)}
1148
+ aria-label={`Delete ${primary.title || singular}`}
1149
+ title="Delete"
1150
+ className="grid size-7 cursor-pointer place-items-center rounded-sm hover:bg-destructive/10"
1151
+ >
1152
+ <Trash2 className="size-4 text-red-500" />
1153
+ </button>
1154
+ </div>
1155
+ </TableCell>
1156
+ <TableCell aria-hidden="true" />
1157
+ </TableRow>
1158
+ );
1159
+ })
1160
+ ) : (
1161
+ <TableRow className="hover:bg-transparent">
1162
+ <TableCell
1163
+ colSpan={visibleFields.length + 4}
1164
+ className="h-32 text-center text-muted-foreground"
1165
+ >
1166
+ {filter ? `No results for “${filter}”.` : "No records yet."}
1167
+ </TableCell>
1168
+ </TableRow>
1169
+ )}
1170
+ </TableBody>
1171
+ </Table>
1172
+ </div>
1173
+ </div>
1174
+ </div>
1175
+
1176
+ {/* Record detail panel */}
1177
+ {activeRow && (
1178
+ <RecordDetailPanel
1179
+ fields={fields}
1180
+ row={activeRow}
1181
+ singular={singular}
1182
+ icon={TitleIcon}
1183
+ getPrimary={getPrimary}
1184
+ readOnly={panelReadOnly}
1185
+ onEdit={() => setPanelReadOnly(false)}
1186
+ onSave={saveForm}
1187
+ onCancel={cancelForm}
1188
+ />
1189
+ )}
1190
+
1191
+ {menu && (
1192
+ <div
1193
+ role="menu"
1194
+ aria-label="Record actions"
1195
+ tabIndex={-1}
1196
+ onMouseDown={(e) => e.stopPropagation()}
1197
+ style={{
1198
+ top: Math.min(menu.y, window.innerHeight - 140),
1199
+ left: Math.min(menu.x, window.innerWidth - 200),
1200
+ }}
1201
+ className="fixed z-50 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
1202
+ >
1203
+ <button
1204
+ type="button"
1205
+ role="menuitem"
1206
+ onClick={() => {
1207
+ setActiveId(menu.id);
1208
+ setMenu(null);
1209
+ }}
1210
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
1211
+ >
1212
+ <ArrowUpRight className="size-3.5" />
1213
+ Open record
1214
+ </button>
1215
+ <button
1216
+ type="button"
1217
+ role="menuitem"
1218
+ onClick={() => {
1219
+ duplicateRow(menu.id);
1220
+ setMenu(null);
1221
+ }}
1222
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
1223
+ >
1224
+ <CopyPlus className="size-3.5" />
1225
+ Duplicate
1226
+ </button>
1227
+ <div className="my-1 h-px bg-border" />
1228
+ <button
1229
+ type="button"
1230
+ role="menuitem"
1231
+ onClick={() => {
1232
+ setConfirmDeleteId(menu.id);
1233
+ setMenu(null);
1234
+ }}
1235
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-destructive hover:bg-destructive/10"
1236
+ >
1237
+ <Trash2 className="size-3.5" />
1238
+ Delete
1239
+ </button>
1240
+ </div>
1241
+ )}
1242
+
1243
+ <ConfirmDialog
1244
+ open={confirmDeleteId != null}
1245
+ title={`Delete ${singular.toLowerCase()}?`}
1246
+ description={
1247
+ <>
1248
+ This permanently removes{" "}
1249
+ <span className="font-medium text-foreground">
1250
+ {deleteTarget ? getPrimary(deleteTarget).title || "this record" : "this record"}
1251
+ </span>
1252
+ . This can’t be undone.
1253
+ </>
1254
+ }
1255
+ destructive
1256
+ confirmLabel="Delete"
1257
+ onConfirm={() => {
1258
+ if (confirmDeleteId != null) deleteRow(confirmDeleteId);
1259
+ setConfirmDeleteId(null);
1260
+ }}
1261
+ onCancel={() => setConfirmDeleteId(null)}
1262
+ />
1263
+
1264
+ <ConfirmDialog
1265
+ open={bulkDeleteOpen}
1266
+ title={`Delete ${selected.size} ${
1267
+ selected.size === 1 ? singular.toLowerCase() : `${title.toLowerCase()}`
1268
+ }?`}
1269
+ description={
1270
+ <>
1271
+ This permanently removes the{" "}
1272
+ <span className="font-medium text-foreground">
1273
+ {selected.size} selected
1274
+ </span>{" "}
1275
+ record{selected.size === 1 ? "" : "s"}. This can’t be undone.
1276
+ </>
1277
+ }
1278
+ destructive
1279
+ confirmLabel="Delete"
1280
+ onConfirm={bulkDelete}
1281
+ onCancel={() => setBulkDeleteOpen(false)}
1282
+ />
1283
+ </div>
1284
+ );
1285
+ }
1286
+
1287
+ interface DetailPanelProps<T extends { id: RowId }> {
1288
+ fields: RecordField<T>[];
1289
+ /** Initial values; the panel edits a local buffered copy until Save. */
1290
+ row: T;
1291
+ singular: string;
1292
+ icon?: IconType;
1293
+ getPrimary: (row: T) => { title: string; initials: string; subtitle?: string };
1294
+ /** Read-only (View) vs editable (Edit / Add). */
1295
+ readOnly?: boolean;
1296
+ /** Switch a read-only panel into edit mode. */
1297
+ onEdit?: () => void;
1298
+ /** Commit the buffered draft to the table. */
1299
+ onSave: (row: T) => void;
1300
+ /** Discard the draft (and drop the row if it was never saved). */
1301
+ onCancel: () => void;
1302
+ }
1303
+
1304
+ function RecordDetailPanel<T extends { id: RowId }>({
1305
+ fields,
1306
+ row,
1307
+ singular,
1308
+ icon: TitleIcon,
1309
+ getPrimary,
1310
+ readOnly = false,
1311
+ onEdit,
1312
+ onSave,
1313
+ onCancel,
1314
+ }: DetailPanelProps<T>) {
1315
+ const [draft, setDraft] = React.useState<T>(row);
1316
+ // Reset the buffered form when a different record is opened.
1317
+ React.useEffect(() => {
1318
+ setDraft(row);
1319
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1320
+ }, [row.id]);
1321
+
1322
+ const primary = getPrimary(draft);
1323
+ const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
1324
+
1325
+ // Required-field validation: keys with an error get a red border on Save.
1326
+ const [errors, setErrors] = React.useState<Set<string>>(new Set());
1327
+ React.useEffect(() => {
1328
+ setErrors(new Set());
1329
+ }, [row.id]);
1330
+
1331
+ const setField = (key: keyof T, value: string) => {
1332
+ setDraft((d) => ({ ...d, [key]: value }));
1333
+ setErrors((prev) => {
1334
+ if (!prev.has(key as string)) return prev;
1335
+ const next = new Set(prev);
1336
+ next.delete(key as string);
1337
+ return next;
1338
+ });
1339
+ };
1340
+
1341
+ // Play the exit animation, then run the actual close/save when it ends.
1342
+ const [closing, setClosing] = React.useState(false);
1343
+ const pending = React.useRef<(() => void) | null>(null);
1344
+ const requestClose = (action: () => void) => {
1345
+ pending.current = action;
1346
+ setClosing(true);
1347
+ };
1348
+ const handleSave = () => {
1349
+ const missing = fields.filter(
1350
+ (f) =>
1351
+ f.required &&
1352
+ f.editable &&
1353
+ !f.render &&
1354
+ String(draft[f.key as keyof T] ?? "").trim() === "",
1355
+ );
1356
+ if (missing.length > 0) {
1357
+ setErrors(new Set(missing.map((f) => f.key)));
1358
+ return;
1359
+ }
1360
+ requestClose(() => onSave(draft));
1361
+ };
1362
+
1363
+ return (
1364
+ <>
1365
+ {/* Dimmed backdrop — click to close. */}
1366
+ <div
1367
+ className={cn(
1368
+ "fixed inset-0 z-[55] bg-foreground/25",
1369
+ closing ? "vui-overlay-out" : "vui-overlay-in",
1370
+ )}
1371
+ onClick={() => requestClose(onCancel)}
1372
+ aria-hidden="true"
1373
+ />
1374
+ <aside
1375
+ aria-label={`${singular} form`}
1376
+ className={cn(
1377
+ "fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-xl sm:w-[380px] sm:max-w-[90vw]",
1378
+ closing ? "vui-panel-out" : "vui-panel-in",
1379
+ )}
1380
+ onAnimationEnd={(e) => {
1381
+ if (e.target === e.currentTarget && closing && pending.current) {
1382
+ const run = pending.current;
1383
+ pending.current = null;
1384
+ run();
1385
+ }
1386
+ }}
1387
+ >
1388
+ {/* Header — icon + title (placeholder when new); matches the page header. */}
1389
+ <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
1390
+ <span className="flex size-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
1391
+ <HeaderIcon className="size-3.5" />
1392
+ </span>
1393
+ <span
1394
+ className={cn(
1395
+ "truncate font-semibold",
1396
+ !primary.title && "text-muted-foreground",
1397
+ )}
1398
+ >
1399
+ {primary.title || `New ${singular}`}
1400
+ </span>
1401
+ <Button
1402
+ variant="ghost"
1403
+ size="icon"
1404
+ onClick={() => requestClose(onCancel)}
1405
+ aria-label="Close"
1406
+ className="ml-auto"
1407
+ >
1408
+ <X className="size-4" />
1409
+ </Button>
1410
+ </div>
1411
+
1412
+ {/* Body — one bordered section per field group. */}
1413
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
1414
+ {GROUP_ORDER.map((group) => {
1415
+ const groupFields = fields.filter(
1416
+ (f) => (f.group ?? "General") === group,
1417
+ );
1418
+ if (groupFields.length === 0) return null;
1419
+ return (
1420
+ <section
1421
+ key={group}
1422
+ className="overflow-hidden rounded-lg border border-border"
1423
+ >
1424
+ <h3 className="border-b border-border bg-muted/40 px-3 py-2 font-medium text-muted-foreground">
1425
+ {group}
1426
+ </h3>
1427
+ <dl className="divide-y divide-border">
1428
+ {groupFields.map((f) => (
1429
+ <div
1430
+ key={f.key}
1431
+ className="flex items-start gap-3 px-3 py-3 leading-relaxed"
1432
+ >
1433
+ <dt className="flex w-28 shrink-0 items-center gap-1.5 pt-1.5 text-muted-foreground">
1434
+ {f.icon && <f.icon className="size-3.5" />}
1435
+ {f.label}
1436
+ {f.required && <RequiredMark />}
1437
+ </dt>
1438
+ <dd className="min-w-0 flex-1">
1439
+ {f.render ? (
1440
+ <div className="pt-0.5">{f.render(draft)}</div>
1441
+ ) : !readOnly && f.editable ? (
1442
+ <textarea
1443
+ value={String(draft[f.key as keyof T] ?? "")}
1444
+ onChange={(e) =>
1445
+ setField(f.key as keyof T, e.target.value)
1446
+ }
1447
+ aria-label={f.label}
1448
+ aria-invalid={errors.has(f.key) || undefined}
1449
+ placeholder={`Add ${f.label.toLowerCase()}`}
1450
+ rows={1}
1451
+ // field-sizing grows the box to fit long/wrapped text
1452
+ className={cn(
1453
+ "w-full resize-none rounded-sm border bg-background px-2 py-1.5 outline-none [field-sizing:content] placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset",
1454
+ errors.has(f.key)
1455
+ ? "border-destructive focus-visible:ring-destructive"
1456
+ : "border-input focus-visible:ring-ring",
1457
+ )}
1458
+ />
1459
+ ) : (
1460
+ <span className="block whitespace-pre-wrap break-words px-2 pt-1.5">
1461
+ {String(draft[f.key as keyof T] ?? "") || (
1462
+ <span className="text-muted-foreground">—</span>
1463
+ )}
1464
+ </span>
1465
+ )}
1466
+ </dd>
1467
+ </div>
1468
+ ))}
1469
+ </dl>
1470
+ </section>
1471
+ );
1472
+ })}
1473
+ </div>
1474
+
1475
+ {/* Footer — View shows Close/Edit; Edit shows Cancel/Save. */}
1476
+ <div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
1477
+ {readOnly ? (
1478
+ <>
1479
+ <Button onClick={() => requestClose(onCancel)}>
1480
+ <X className="size-4" />
1481
+ Close
1482
+ </Button>
1483
+ {onEdit && (
1484
+ <Button variant="primary" onClick={onEdit}>
1485
+ <Pencil className="size-4" />
1486
+ Edit
1487
+ </Button>
1488
+ )}
1489
+ </>
1490
+ ) : (
1491
+ <>
1492
+ <Button onClick={() => requestClose(onCancel)}>
1493
+ <X className="size-4" />
1494
+ Cancel
1495
+ </Button>
1496
+ <Button variant="primary" onClick={handleSave}>
1497
+ <Check className="size-4" />
1498
+ Save
1499
+ </Button>
1500
+ </>
1501
+ )}
1502
+ </div>
1503
+ </aside>
1504
+ </>
1505
+ );
1506
+ }