@viliha/vui-ui 1.0.10 → 1.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.0.10",
3
+ "version": "1.1.0",
4
4
  "description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
5
5
  "license": "MIT",
6
6
  "author": "Suman Bonakurthi",
@@ -0,0 +1,97 @@
1
+ import * as React from "react";
2
+ import {
3
+ ArrowLeftIcon as ArrowLeft,
4
+ ChevronRightIcon as ChevronRight,
5
+ } from "@radix-ui/react-icons";
6
+
7
+ import { cn } from "./utils";
8
+
9
+ export interface Crumb {
10
+ label: React.ReactNode;
11
+ /** Navigate via a link (rendered with `linkComponent` if given, else <a>). */
12
+ href?: string;
13
+ /** Navigate via a handler (rendered as a button). Ignored if href is set. */
14
+ onClick?: () => void;
15
+ }
16
+
17
+ /** Minimal link contract so an app can pass its router link (e.g. next/link). */
18
+ type LinkComponent = React.ComponentType<{
19
+ href: string;
20
+ className?: string;
21
+ children: React.ReactNode;
22
+ }>;
23
+
24
+ const CRUMB_LINK =
25
+ "shrink-0 text-muted-foreground transition-colors hover:text-foreground";
26
+
27
+ /**
28
+ * Shared breadcrumb trail — one style used everywhere (route pages and forms).
29
+ * The LAST crumb is the current page (bold, non-interactive); earlier crumbs
30
+ * are links (href) or buttons (onClick).
31
+ */
32
+ export function Breadcrumbs({
33
+ crumbs,
34
+ onBack,
35
+ linkComponent: Link,
36
+ className,
37
+ }: {
38
+ crumbs: Crumb[];
39
+ /** Optional back button shown before the trail. */
40
+ onBack?: () => void;
41
+ linkComponent?: LinkComponent;
42
+ className?: string;
43
+ }) {
44
+ return (
45
+ <div className={cn("flex min-w-0 items-center gap-2 text-sm", className)}>
46
+ {onBack && (
47
+ <button
48
+ type="button"
49
+ onClick={onBack}
50
+ aria-label="Go back"
51
+ title="Back"
52
+ className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
53
+ >
54
+ <ArrowLeft className="size-3.5" />
55
+ </button>
56
+ )}
57
+ <nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1">
58
+ {crumbs.map((c, i) => {
59
+ const isLast = i === crumbs.length - 1;
60
+ return (
61
+ <span key={i} className="flex min-w-0 items-center gap-1">
62
+ {isLast ? (
63
+ <span className="truncate font-medium text-foreground">
64
+ {c.label}
65
+ </span>
66
+ ) : (
67
+ <>
68
+ {c.href && Link ? (
69
+ <Link href={c.href} className={CRUMB_LINK}>
70
+ {c.label}
71
+ </Link>
72
+ ) : c.href ? (
73
+ <a href={c.href} className={CRUMB_LINK}>
74
+ {c.label}
75
+ </a>
76
+ ) : (
77
+ <button
78
+ type="button"
79
+ onClick={c.onClick}
80
+ className={CRUMB_LINK}
81
+ >
82
+ {c.label}
83
+ </button>
84
+ )}
85
+ <ChevronRight
86
+ className="size-3 shrink-0 text-muted-foreground/60"
87
+ aria-hidden="true"
88
+ />
89
+ </>
90
+ )}
91
+ </span>
92
+ );
93
+ })}
94
+ </nav>
95
+ </div>
96
+ );
97
+ }
@@ -22,6 +22,7 @@ import {
22
22
  DotsHorizontalIcon as MoreHorizontal,
23
23
  DragHandleDots2Icon as GripVertical,
24
24
  EyeOpenIcon as Eye,
25
+ InfoCircledIcon as Info,
25
26
  MagnifyingGlassIcon as Search,
26
27
  MixerHorizontalIcon as ListFilter,
27
28
  MixerHorizontalIcon as SlidersHorizontal,
@@ -32,6 +33,7 @@ import {
32
33
  } from "@radix-ui/react-icons";
33
34
 
34
35
  import { cn } from "./utils";
36
+ import { Breadcrumbs, type Crumb } from "./breadcrumbs";
35
37
  import { Button } from "./button";
36
38
  import { Checkbox } from "./checkbox";
37
39
  import { Input } from "./input";
@@ -171,6 +173,8 @@ export function usePageTitle(title: string, icon?: IconType) {
171
173
  export interface RecordField<T> {
172
174
  key: Extract<keyof T, string>;
173
175
  label: string;
176
+ /** Help text shown in the page-form documentation panel. */
177
+ description?: string;
174
178
  icon?: IconType;
175
179
  editable?: boolean;
176
180
  /** Mark the field mandatory — shows a `*` next to its label. */
@@ -204,6 +208,26 @@ interface RecordViewProps<T extends { id: RowId }> {
204
208
  initials: string;
205
209
  subtitle?: string;
206
210
  };
211
+ /** Add/Edit form presentation: "panel" slide-over (default) or "page" full-page. */
212
+ formMode?: "panel" | "page";
213
+ /** Full-page form column count (page mode only). Default 1. */
214
+ formColumns?: 1 | 2;
215
+ /** Navigate to Home from the page-form breadcrumb (e.g. router.push). */
216
+ onHome?: () => void;
217
+ /** Intro text for the page-form documentation panel ("about this form"). */
218
+ formDescription?: string;
219
+ /** Controlled rows. When set, RecordView renders these and reports edits via
220
+ * onDataChange instead of holding rows in internal state. */
221
+ data?: T[];
222
+ /** Receives the next rows array in controlled mode. */
223
+ onDataChange?: (rows: T[]) => void;
224
+ /** When set, the "add" button calls this (e.g. navigate to a create route)
225
+ * instead of opening the built-in form. */
226
+ onCreate?: () => void;
227
+ /** When set, opening/editing a row navigates (e.g. to an edit route) instead
228
+ * of opening the built-in overlay form. */
229
+ onView?: (id: RowId) => void;
230
+ onEdit?: (id: RowId) => void;
207
231
  }
208
232
 
209
233
  export function RecordView<T extends { id: RowId }>({
@@ -214,11 +238,37 @@ export function RecordView<T extends { id: RowId }>({
214
238
  initialData,
215
239
  makeEmptyRow,
216
240
  getPrimary,
241
+ formMode = "panel",
242
+ formColumns = 1,
243
+ onHome,
244
+ formDescription,
245
+ data,
246
+ onDataChange,
247
+ onCreate,
248
+ onView,
249
+ onEdit,
217
250
  }: RecordViewProps<T>) {
218
251
  const { titleLeading } = React.useContext(PageChromeContext);
219
252
  // Surface the page title/icon in the app's global top bar.
220
253
  usePageTitle(title, TitleIcon);
221
- const [rows, setRows] = React.useState<T[]>(initialData);
254
+ // Rows are either controlled (data + onDataChange) or held internally.
255
+ const [internalRows, setInternalRows] = React.useState<T[]>(initialData);
256
+ const controlled = data !== undefined;
257
+ const rows = controlled ? data : internalRows;
258
+ const setRows = React.useCallback(
259
+ (updater: React.SetStateAction<T[]>) => {
260
+ if (controlled) {
261
+ const next =
262
+ typeof updater === "function"
263
+ ? (updater as (prev: T[]) => T[])(data as T[])
264
+ : updater;
265
+ onDataChange?.(next);
266
+ } else {
267
+ setInternalRows(updater);
268
+ }
269
+ },
270
+ [controlled, data, onDataChange],
271
+ );
222
272
  const [filter, setFilter] = React.useState("");
223
273
  const [sort, setSort] = React.useState<{
224
274
  key: string;
@@ -449,6 +499,11 @@ export function RecordView<T extends { id: RowId }>({
449
499
  setBulkDeleteOpen(false);
450
500
  }
451
501
  function addRow() {
502
+ // Routed create: delegate to the caller (e.g. navigate to /new).
503
+ if (onCreate) {
504
+ onCreate();
505
+ return;
506
+ }
452
507
  const row = { ...makeEmptyRow(), id: nextId.current++ };
453
508
  // Prepend so the new record is immediately visible at the top…
454
509
  setRows((prev) => [row, ...prev]);
@@ -459,11 +514,19 @@ export function RecordView<T extends { id: RowId }>({
459
514
  }
460
515
  /** Open the detail panel read-only (View). */
461
516
  function openView(id: RowId) {
517
+ if (onView) {
518
+ onView(id);
519
+ return;
520
+ }
462
521
  setPanelReadOnly(true);
463
522
  setActiveId(id);
464
523
  }
465
524
  /** Open the detail panel editable (Edit). */
466
525
  function openEdit(id: RowId) {
526
+ if (onEdit) {
527
+ onEdit(id);
528
+ return;
529
+ }
467
530
  setPanelReadOnly(false);
468
531
  setActiveId(id);
469
532
  }
@@ -701,6 +764,30 @@ export function RecordView<T extends { id: RowId }>({
701
764
  );
702
765
  }
703
766
 
767
+ // Full-page form mode: replace the table chrome entirely while adding/editing
768
+ // (this also hides the import/export/add actions, which live in that chrome).
769
+ if (formMode === "page" && activeRow) {
770
+ return (
771
+ <RecordDetailPanel
772
+ layout="page"
773
+ columns={formColumns}
774
+ isNew={activeId === newRowId}
775
+ title={title}
776
+ onHome={onHome}
777
+ formDescription={formDescription}
778
+ fields={fields}
779
+ row={activeRow}
780
+ singular={singular}
781
+ icon={TitleIcon}
782
+ getPrimary={getPrimary}
783
+ readOnly={panelReadOnly}
784
+ onEdit={() => setPanelReadOnly(false)}
785
+ onSave={saveForm}
786
+ onCancel={cancelForm}
787
+ />
788
+ );
789
+ }
790
+
704
791
  return (
705
792
  <div className="flex h-full flex-col">
706
793
  {/* Header — title/icon now live in the global top bar; this row holds the
@@ -951,7 +1038,10 @@ export function RecordView<T extends { id: RowId }>({
951
1038
  <TableHeader className="sticky top-0 z-20 bg-background [&_th]:sticky [&_th]:top-0 [&_th]:z-20 [&_th]:bg-background">
952
1039
  <TableRow className="hover:bg-transparent">
953
1040
  <TableHead style={{ width: CHECKBOX_W }} className="p-0">
954
- <div className="flex h-8 items-center pl-8">
1041
+ <div className="flex h-8 items-center gap-2 pl-2 pr-3">
1042
+ {/* Spacer matching the row drag-grip slot so this checkbox
1043
+ lines up vertically with the row checkboxes below. */}
1044
+ <span aria-hidden="true" className="h-6 w-4 shrink-0" />
955
1045
  <Checkbox
956
1046
  checked={allSelected}
957
1047
  onChange={toggleSelectAll}
@@ -1002,16 +1092,18 @@ export function RecordView<T extends { id: RowId }>({
1002
1092
  </TableHead>
1003
1093
  );
1004
1094
  })}
1095
+ {/* Flex spacer absorbs leftover width so data columns keep their
1096
+ natural size AND the Actions column stays pinned to the right
1097
+ edge. Borderless so no stray divider shows in the gap. */}
1098
+ <TableHead aria-hidden="true" className="w-full border-r-0" />
1005
1099
  <TableHead
1006
1100
  style={{ width: ACTIONS_W }}
1007
- className="border-r-0 text-right"
1101
+ className="sticky right-0 z-30 border-l border-border text-right shadow-[-8px_0_12px_-8px_rgb(0_0_0/0.12)]"
1008
1102
  >
1009
- <span className="flex h-8 items-center justify-end whitespace-nowrap pr-2">
1103
+ <span className="flex h-8 items-center justify-center whitespace-nowrap px-2">
1010
1104
  Actions
1011
1105
  </span>
1012
1106
  </TableHead>
1013
- {/* Filler so row borders reach the right edge of the page. */}
1014
- <TableHead aria-hidden="true" />
1015
1107
  </TableRow>
1016
1108
  </TableHeader>
1017
1109
  <TableBody>
@@ -1043,12 +1135,12 @@ export function RecordView<T extends { id: RowId }>({
1043
1135
  }}
1044
1136
  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"
1045
1137
  >
1046
- <TableCell className="p-0">
1047
- <div className="relative flex h-8 items-center pl-8">
1138
+ <TableCell className="p-0" style={{ width: CHECKBOX_W }}>
1139
+ <div className="flex h-8 items-center gap-2 pl-2 pr-3">
1048
1140
  {/* Drag grip — always visible in a light color (so the
1049
1141
  reorder affordance is discoverable), darkening on
1050
- hover. Sits in its own reserved slot in the left
1051
- gutter so it never overlaps the checkbox. */}
1142
+ hover. Inline before the checkbox; plain glyph (no
1143
+ icon-chip border) so it doesn't read as a box. */}
1052
1144
  <div
1053
1145
  draggable
1054
1146
  onDragStart={(e) => {
@@ -1062,9 +1154,9 @@ export function RecordView<T extends { id: RowId }>({
1062
1154
  }}
1063
1155
  aria-label={`Drag ${primary.title || singular} to reorder`}
1064
1156
  title="Drag to reorder"
1065
- className="absolute left-1.5 top-1/2 flex h-6 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-muted-foreground/40 transition-colors hover:text-foreground active:cursor-grabbing"
1157
+ className="flex h-6 w-4 shrink-0 cursor-grab items-center justify-center text-muted-foreground/40 transition-colors hover:text-foreground active:cursor-grabbing"
1066
1158
  >
1067
- <GripVertical className="size-3.5" />
1159
+ <GripVertical className="size-3.5 border-transparent bg-transparent" />
1068
1160
  </div>
1069
1161
  <Checkbox
1070
1162
  checked={selected.has(row.id)}
@@ -1099,11 +1191,12 @@ export function RecordView<T extends { id: RowId }>({
1099
1191
  {renderCellValue(row, f)}
1100
1192
  </TableCell>
1101
1193
  ))}
1194
+ <TableCell aria-hidden="true" className="w-full border-r-0" />
1102
1195
  <TableCell
1103
- className="border-r-0 p-0"
1196
+ className="sticky right-0 z-10 border-l border-border bg-card p-0 shadow-[-8px_0_12px_-8px_rgb(0_0_0/0.12)]"
1104
1197
  style={{ width: ACTIONS_W }}
1105
1198
  >
1106
- <div className="flex items-center justify-end gap-0.5 pr-2">
1199
+ <div className="flex items-center justify-center gap-0.5 px-2">
1107
1200
  <button
1108
1201
  type="button"
1109
1202
  onClick={() => openView(row.id)}
@@ -1133,7 +1226,6 @@ export function RecordView<T extends { id: RowId }>({
1133
1226
  </button>
1134
1227
  </div>
1135
1228
  </TableCell>
1136
- <TableCell aria-hidden="true" />
1137
1229
  </TableRow>
1138
1230
  );
1139
1231
  })
@@ -1184,7 +1276,7 @@ export function RecordView<T extends { id: RowId }>({
1184
1276
  type="button"
1185
1277
  role="menuitem"
1186
1278
  onClick={() => {
1187
- setActiveId(menu.id);
1279
+ openView(menu.id);
1188
1280
  setMenu(null);
1189
1281
  }}
1190
1282
  className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
@@ -1279,6 +1371,18 @@ interface DetailPanelProps<T extends { id: RowId }> {
1279
1371
  onSave: (row: T) => void;
1280
1372
  /** Discard the draft (and drop the row if it was never saved). */
1281
1373
  onCancel: () => void;
1374
+ /** "panel" = slide-over (default); "page" = full-page form. */
1375
+ layout?: "panel" | "page";
1376
+ /** Full-page form column count. Default 1. */
1377
+ columns?: 1 | 2;
1378
+ /** New (unsaved) record — drives the "Create new …" breadcrumb. */
1379
+ isNew?: boolean;
1380
+ /** Plural collection title (e.g. "Organizations") — the clickable parent crumb. */
1381
+ title?: string;
1382
+ /** Navigate to Home from the breadcrumb. */
1383
+ onHome?: () => void;
1384
+ /** Intro text for the documentation panel. */
1385
+ formDescription?: string;
1282
1386
  }
1283
1387
 
1284
1388
  function RecordDetailPanel<T extends { id: RowId }>({
@@ -1291,6 +1395,12 @@ function RecordDetailPanel<T extends { id: RowId }>({
1291
1395
  onEdit,
1292
1396
  onSave,
1293
1397
  onCancel,
1398
+ layout = "panel",
1399
+ columns = 1,
1400
+ isNew = false,
1401
+ title,
1402
+ onHome,
1403
+ formDescription,
1294
1404
  }: DetailPanelProps<T>) {
1295
1405
  const [draft, setDraft] = React.useState<T>(row);
1296
1406
  // Reset the buffered form when a different record is opened.
@@ -1325,6 +1435,10 @@ function RecordDetailPanel<T extends { id: RowId }>({
1325
1435
  pending.current = action;
1326
1436
  setClosing(true);
1327
1437
  };
1438
+ // The page layout has no slide-out animation — run the action immediately.
1439
+ const dismiss = (action: () => void) =>
1440
+ layout === "page" ? action() : requestClose(action);
1441
+
1328
1442
  const handleSave = () => {
1329
1443
  const missing = fields.filter(
1330
1444
  (f) =>
@@ -1337,9 +1451,181 @@ function RecordDetailPanel<T extends { id: RowId }>({
1337
1451
  setErrors(new Set(missing.map((f) => f.key)));
1338
1452
  return;
1339
1453
  }
1340
- requestClose(() => onSave(draft));
1454
+ dismiss(() => onSave(draft));
1341
1455
  };
1342
1456
 
1457
+ // Grouped field sections — shared by the slide-over and full-page layouts.
1458
+ const formBody = GROUP_ORDER.map((group) => {
1459
+ const groupFields = fields.filter((f) => (f.group ?? "General") === group);
1460
+ if (groupFields.length === 0) return null;
1461
+ return (
1462
+ <section
1463
+ key={group}
1464
+ className="overflow-hidden rounded-lg border border-border"
1465
+ >
1466
+ <h3 className="border-b border-border bg-muted/40 px-3 py-2 font-semibold text-[var(--button-primary)]">
1467
+ {group}
1468
+ </h3>
1469
+ <dl className="divide-y divide-border">
1470
+ {groupFields.map((f) => (
1471
+ <div
1472
+ key={f.key}
1473
+ className="flex items-start gap-3 px-3 py-3 leading-relaxed"
1474
+ >
1475
+ <dt className="flex w-28 shrink-0 items-center gap-1.5 pt-1.5 text-muted-foreground">
1476
+ {f.icon && (
1477
+ <f.icon className="size-3.5 text-[var(--button-primary)]" />
1478
+ )}
1479
+ {f.label}
1480
+ {f.required && <RequiredMark />}
1481
+ </dt>
1482
+ <dd className="min-w-0 flex-1">
1483
+ {f.render ? (
1484
+ <div className="pt-0.5">{f.render(draft)}</div>
1485
+ ) : !readOnly && f.editable ? (
1486
+ <textarea
1487
+ value={String(draft[f.key as keyof T] ?? "")}
1488
+ onChange={(e) => setField(f.key as keyof T, e.target.value)}
1489
+ aria-label={f.label}
1490
+ aria-invalid={errors.has(f.key) || undefined}
1491
+ placeholder={`Add ${f.label.toLowerCase()}`}
1492
+ rows={1}
1493
+ // field-sizing grows the box to fit long/wrapped text
1494
+ className={cn(
1495
+ "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",
1496
+ errors.has(f.key)
1497
+ ? "border-destructive focus-visible:ring-destructive"
1498
+ : "border-input focus-visible:ring-ring",
1499
+ )}
1500
+ />
1501
+ ) : (
1502
+ <span className="block whitespace-pre-wrap break-words px-2 pt-1.5">
1503
+ {String(draft[f.key as keyof T] ?? "") || (
1504
+ <span className="text-muted-foreground">—</span>
1505
+ )}
1506
+ </span>
1507
+ )}
1508
+ </dd>
1509
+ </div>
1510
+ ))}
1511
+ </dl>
1512
+ </section>
1513
+ );
1514
+ });
1515
+
1516
+ // Footer actions — View shows Close/Edit; Add/Edit shows Cancel/Save.
1517
+ const formFooter = (
1518
+ <div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
1519
+ {readOnly ? (
1520
+ <>
1521
+ <Button onClick={() => dismiss(onCancel)}>
1522
+ <X className="size-4" />
1523
+ Close
1524
+ </Button>
1525
+ {onEdit && (
1526
+ <Button variant="primary" onClick={onEdit}>
1527
+ <Pencil className="size-4" />
1528
+ Edit
1529
+ </Button>
1530
+ )}
1531
+ </>
1532
+ ) : (
1533
+ <>
1534
+ <Button onClick={() => dismiss(onCancel)}>
1535
+ <X className="size-4" />
1536
+ Cancel
1537
+ </Button>
1538
+ <Button variant="primary" onClick={handleSave}>
1539
+ <Check className="size-4" />
1540
+ Save
1541
+ </Button>
1542
+ </>
1543
+ )}
1544
+ </div>
1545
+ );
1546
+
1547
+ // Full-page form: breadcrumb header → scrollable single column → fixed actions.
1548
+ if (layout === "page") {
1549
+ const crumb = readOnly
1550
+ ? primary.title || `View ${singular.toLowerCase()}`
1551
+ : isNew
1552
+ ? `Create new ${singular.toLowerCase()}`
1553
+ : `Update ${singular.toLowerCase()}`;
1554
+ // AWS-style documentation column: an intro plus per-field help text.
1555
+ const documentedFields = fields.filter((f) => f.description);
1556
+ const docPanel =
1557
+ formDescription || documentedFields.length > 0 ? (
1558
+ <aside
1559
+ aria-label={`${title ?? singular} help`}
1560
+ className="hidden w-80 shrink-0 overflow-y-auto rounded-lg border border-border bg-muted/20 lg:block"
1561
+ >
1562
+ <div className="space-y-4 p-4 text-sm">
1563
+ <div className="space-y-1.5">
1564
+ <h2 className="flex items-center gap-1.5 font-semibold text-[var(--button-primary)]">
1565
+ <Info className="size-4 text-[var(--button-primary)]" />
1566
+ About {title ?? singular}
1567
+ </h2>
1568
+ {formDescription && (
1569
+ <p className="leading-relaxed text-muted-foreground">
1570
+ {formDescription}
1571
+ </p>
1572
+ )}
1573
+ </div>
1574
+ {documentedFields.length > 0 && (
1575
+ <dl className="divide-y divide-border border-t border-border">
1576
+ {documentedFields.map((f) => (
1577
+ <div key={f.key} className="space-y-0.5 py-3 first:pt-4">
1578
+ <dt className="font-medium text-foreground">{f.label}</dt>
1579
+ <dd className="leading-relaxed text-muted-foreground">
1580
+ {f.description}
1581
+ </dd>
1582
+ </div>
1583
+ ))}
1584
+ </dl>
1585
+ )}
1586
+ </div>
1587
+ </aside>
1588
+ ) : null;
1589
+ return (
1590
+ <div className="flex h-full flex-col">
1591
+ {/* Breadcrumb — the shared Breadcrumbs component (consistent app-wide). */}
1592
+ <div className="flex h-12 shrink-0 items-center border-b border-border px-4">
1593
+ <Breadcrumbs
1594
+ onBack={onCancel}
1595
+ crumbs={[
1596
+ ...(onHome ? [{ label: "Home", onClick: onHome }] : []),
1597
+ { label: title ?? singular, onClick: onCancel },
1598
+ { label: crumb },
1599
+ ] as Crumb[]}
1600
+ />
1601
+ </div>
1602
+ {/* Content — form card (left) + optional documentation panel (right). */}
1603
+ <div className="min-h-0 flex-1 overflow-hidden p-4">
1604
+ <div className="flex h-full gap-4">
1605
+ {/* Padded, bordered card — matches the datatable content container. */}
1606
+ <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
1607
+ <div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
1608
+ <div
1609
+ className={cn(
1610
+ "w-full",
1611
+ columns === 2
1612
+ ? "mx-auto grid max-w-5xl grid-cols-1 items-start gap-4 md:grid-cols-2"
1613
+ : // One column: sections span the full container width.
1614
+ "space-y-4",
1615
+ )}
1616
+ >
1617
+ {formBody}
1618
+ </div>
1619
+ </div>
1620
+ {formFooter}
1621
+ </div>
1622
+ {docPanel}
1623
+ </div>
1624
+ </div>
1625
+ </div>
1626
+ );
1627
+ }
1628
+
1343
1629
  return (
1344
1630
  <>
1345
1631
  {/* Dimmed backdrop — click to close. */}
@@ -1365,122 +1651,48 @@ function RecordDetailPanel<T extends { id: RowId }>({
1365
1651
  }
1366
1652
  }}
1367
1653
  >
1368
- {/* Header — icon + title (placeholder when new); matches the page header. */}
1369
- <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
1370
- <span className="flex size-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
1371
- <HeaderIcon className="size-3.5" />
1372
- </span>
1373
- <span
1374
- className={cn(
1375
- "truncate font-semibold",
1376
- !primary.title && "text-muted-foreground",
1377
- )}
1378
- >
1379
- {primary.title || `New ${singular}`}
1380
- </span>
1381
- <Button
1382
- variant="ghost"
1383
- size="icon"
1384
- onClick={() => requestClose(onCancel)}
1385
- aria-label="Close"
1386
- className="ml-auto"
1387
- >
1388
- <X className="size-4" />
1389
- </Button>
1390
- </div>
1654
+ {/* Header — icon + title (placeholder when new); matches the page header. */}
1655
+ <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
1656
+ <span className="flex size-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
1657
+ <HeaderIcon className="size-3.5" />
1658
+ </span>
1659
+ <span
1660
+ className={cn(
1661
+ "truncate font-semibold",
1662
+ !primary.title && "text-muted-foreground",
1663
+ )}
1664
+ >
1665
+ {primary.title || `New ${singular}`}
1666
+ </span>
1667
+ <Button
1668
+ variant="ghost"
1669
+ size="icon"
1670
+ onClick={() => requestClose(onCancel)}
1671
+ aria-label="Close"
1672
+ className="ml-auto"
1673
+ >
1674
+ <X className="size-4" />
1675
+ </Button>
1676
+ </div>
1391
1677
 
1392
- {/* Body — one bordered section per field group. */}
1393
- <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
1394
- {GROUP_ORDER.map((group) => {
1395
- const groupFields = fields.filter(
1396
- (f) => (f.group ?? "General") === group,
1397
- );
1398
- if (groupFields.length === 0) return null;
1399
- return (
1400
- <section
1401
- key={group}
1402
- className="overflow-hidden rounded-lg border border-border"
1403
- >
1404
- <h3 className="border-b border-border bg-muted/40 px-3 py-2 font-medium text-muted-foreground">
1405
- {group}
1406
- </h3>
1407
- <dl className="divide-y divide-border">
1408
- {groupFields.map((f) => (
1409
- <div
1410
- key={f.key}
1411
- className="flex items-start gap-3 px-3 py-3 leading-relaxed"
1412
- >
1413
- <dt className="flex w-28 shrink-0 items-center gap-1.5 pt-1.5 text-muted-foreground">
1414
- {f.icon && <f.icon className="size-3.5" />}
1415
- {f.label}
1416
- {f.required && <RequiredMark />}
1417
- </dt>
1418
- <dd className="min-w-0 flex-1">
1419
- {f.render ? (
1420
- <div className="pt-0.5">{f.render(draft)}</div>
1421
- ) : !readOnly && f.editable ? (
1422
- <textarea
1423
- value={String(draft[f.key as keyof T] ?? "")}
1424
- onChange={(e) =>
1425
- setField(f.key as keyof T, e.target.value)
1426
- }
1427
- aria-label={f.label}
1428
- aria-invalid={errors.has(f.key) || undefined}
1429
- placeholder={`Add ${f.label.toLowerCase()}`}
1430
- rows={1}
1431
- // field-sizing grows the box to fit long/wrapped text
1432
- className={cn(
1433
- "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",
1434
- errors.has(f.key)
1435
- ? "border-destructive focus-visible:ring-destructive"
1436
- : "border-input focus-visible:ring-ring",
1437
- )}
1438
- />
1439
- ) : (
1440
- <span className="block whitespace-pre-wrap break-words px-2 pt-1.5">
1441
- {String(draft[f.key as keyof T] ?? "") || (
1442
- <span className="text-muted-foreground">—</span>
1443
- )}
1444
- </span>
1445
- )}
1446
- </dd>
1447
- </div>
1448
- ))}
1449
- </dl>
1450
- </section>
1451
- );
1452
- })}
1453
- </div>
1678
+ {/* Body — one bordered section per field group. */}
1679
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
1680
+ {formBody}
1681
+ </div>
1454
1682
 
1455
- {/* Footer — View shows Close/Edit; Edit shows Cancel/Save. */}
1456
- <div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
1457
- {readOnly ? (
1458
- <>
1459
- <Button onClick={() => requestClose(onCancel)}>
1460
- <X className="size-4" />
1461
- Close
1462
- </Button>
1463
- {onEdit && (
1464
- <Button variant="primary" onClick={onEdit}>
1465
- <Pencil className="size-4" />
1466
- Edit
1467
- </Button>
1468
- )}
1469
- </>
1470
- ) : (
1471
- <>
1472
- <Button onClick={() => requestClose(onCancel)}>
1473
- <X className="size-4" />
1474
- Cancel
1475
- </Button>
1476
- <Button variant="primary" onClick={handleSave}>
1477
- <Check className="size-4" />
1478
- Save
1479
- </Button>
1480
- </>
1481
- )}
1482
- </div>
1683
+ {formFooter}
1483
1684
  </aside>
1484
1685
  </>
1485
1686
  );
1486
1687
  }
1688
+
1689
+ /**
1690
+ * Standalone full-page record form for a dedicated route (e.g. `/…/new`).
1691
+ * Wraps the page layout of the detail panel so the same form/breadcrumb/doc
1692
+ * chrome is reused outside the table.
1693
+ */
1694
+ export function RecordForm<T extends { id: RowId }>(
1695
+ props: Omit<DetailPanelProps<T>, "layout">,
1696
+ ) {
1697
+ return <RecordDetailPanel layout="page" {...props} />;
1698
+ }
package/src/theme.css CHANGED
@@ -32,7 +32,8 @@
32
32
  --destructive-foreground: oklch(0.985 0 0);
33
33
  --border: oklch(0.922 0 0);
34
34
  --input: oklch(0.922 0 0);
35
- --ring: oklch(0.708 0 0);
35
+ /* Focus ring uses the brand primary so highlighted fields read as "active". */
36
+ --ring: var(--button-primary);
36
37
  /* Per-page accent (overridden by the app per route) — tints module icons. */
37
38
  --page-accent: var(--muted-foreground);
38
39
  /* Text highlight (::selection) */
@@ -87,7 +88,7 @@
87
88
  --destructive-foreground: oklch(0.985 0 0);
88
89
  --border: oklch(1 0 0 / 10%);
89
90
  --input: oklch(1 0 0 / 15%);
90
- --ring: oklch(0.556 0 0);
91
+ --ring: var(--button-primary);
91
92
  --chart-1: oklch(0.488 0.243 264.376);
92
93
  --chart-2: oklch(0.696 0.17 162.48);
93
94
  --chart-3: oklch(0.769 0.188 70.08);