@viliha/vui-ui 1.0.11 → 1.1.1

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/AGENT.md CHANGED
@@ -226,6 +226,20 @@ Allow RecordView to manage:
226
226
  - bulk actions
227
227
  - import/export
228
228
 
229
+ ## Add / edit form
230
+
231
+ The buffered add/edit form renders in one of two layouts:
232
+
233
+ - **Slide-over panel** — the default; nothing to configure.
234
+ - **Full-page form** — set `formMode="page"` (with `formColumns={1 | 2}`). Add
235
+ `formDescription` and a per-field `description` to show an AWS-style help
236
+ panel beside the form.
237
+
238
+ For the form on its own URL, render the exported `RecordForm` on a route and use
239
+ controlled data (`data` + `onDataChange`) with `onCreate` / `onView` / `onEdit`
240
+ to navigate instead of opening the overlay. Breadcrumbs everywhere come from the
241
+ shared `@viliha/vui-ui/breadcrumbs` component.
242
+
229
243
  ---
230
244
 
231
245
  # Charts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.0.11",
3
+ "version": "1.1.1",
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
@@ -1189,7 +1276,7 @@ export function RecordView<T extends { id: RowId }>({
1189
1276
  type="button"
1190
1277
  role="menuitem"
1191
1278
  onClick={() => {
1192
- setActiveId(menu.id);
1279
+ openView(menu.id);
1193
1280
  setMenu(null);
1194
1281
  }}
1195
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"
@@ -1284,6 +1371,18 @@ interface DetailPanelProps<T extends { id: RowId }> {
1284
1371
  onSave: (row: T) => void;
1285
1372
  /** Discard the draft (and drop the row if it was never saved). */
1286
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;
1287
1386
  }
1288
1387
 
1289
1388
  function RecordDetailPanel<T extends { id: RowId }>({
@@ -1296,6 +1395,12 @@ function RecordDetailPanel<T extends { id: RowId }>({
1296
1395
  onEdit,
1297
1396
  onSave,
1298
1397
  onCancel,
1398
+ layout = "panel",
1399
+ columns = 1,
1400
+ isNew = false,
1401
+ title,
1402
+ onHome,
1403
+ formDescription,
1299
1404
  }: DetailPanelProps<T>) {
1300
1405
  const [draft, setDraft] = React.useState<T>(row);
1301
1406
  // Reset the buffered form when a different record is opened.
@@ -1330,6 +1435,10 @@ function RecordDetailPanel<T extends { id: RowId }>({
1330
1435
  pending.current = action;
1331
1436
  setClosing(true);
1332
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
+
1333
1442
  const handleSave = () => {
1334
1443
  const missing = fields.filter(
1335
1444
  (f) =>
@@ -1342,9 +1451,181 @@ function RecordDetailPanel<T extends { id: RowId }>({
1342
1451
  setErrors(new Set(missing.map((f) => f.key)));
1343
1452
  return;
1344
1453
  }
1345
- requestClose(() => onSave(draft));
1454
+ dismiss(() => onSave(draft));
1346
1455
  };
1347
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-y border-border bg-muted/40 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
+
1348
1629
  return (
1349
1630
  <>
1350
1631
  {/* Dimmed backdrop — click to close. */}
@@ -1370,122 +1651,48 @@ function RecordDetailPanel<T extends { id: RowId }>({
1370
1651
  }
1371
1652
  }}
1372
1653
  >
1373
- {/* Header — icon + title (placeholder when new); matches the page header. */}
1374
- <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
1375
- <span className="flex size-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
1376
- <HeaderIcon className="size-3.5" />
1377
- </span>
1378
- <span
1379
- className={cn(
1380
- "truncate font-semibold",
1381
- !primary.title && "text-muted-foreground",
1382
- )}
1383
- >
1384
- {primary.title || `New ${singular}`}
1385
- </span>
1386
- <Button
1387
- variant="ghost"
1388
- size="icon"
1389
- onClick={() => requestClose(onCancel)}
1390
- aria-label="Close"
1391
- className="ml-auto"
1392
- >
1393
- <X className="size-4" />
1394
- </Button>
1395
- </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>
1396
1677
 
1397
- {/* Body — one bordered section per field group. */}
1398
- <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
1399
- {GROUP_ORDER.map((group) => {
1400
- const groupFields = fields.filter(
1401
- (f) => (f.group ?? "General") === group,
1402
- );
1403
- if (groupFields.length === 0) return null;
1404
- return (
1405
- <section
1406
- key={group}
1407
- className="overflow-hidden rounded-lg border border-border"
1408
- >
1409
- <h3 className="border-b border-border bg-muted/40 px-3 py-2 font-medium text-muted-foreground">
1410
- {group}
1411
- </h3>
1412
- <dl className="divide-y divide-border">
1413
- {groupFields.map((f) => (
1414
- <div
1415
- key={f.key}
1416
- className="flex items-start gap-3 px-3 py-3 leading-relaxed"
1417
- >
1418
- <dt className="flex w-28 shrink-0 items-center gap-1.5 pt-1.5 text-muted-foreground">
1419
- {f.icon && <f.icon className="size-3.5" />}
1420
- {f.label}
1421
- {f.required && <RequiredMark />}
1422
- </dt>
1423
- <dd className="min-w-0 flex-1">
1424
- {f.render ? (
1425
- <div className="pt-0.5">{f.render(draft)}</div>
1426
- ) : !readOnly && f.editable ? (
1427
- <textarea
1428
- value={String(draft[f.key as keyof T] ?? "")}
1429
- onChange={(e) =>
1430
- setField(f.key as keyof T, e.target.value)
1431
- }
1432
- aria-label={f.label}
1433
- aria-invalid={errors.has(f.key) || undefined}
1434
- placeholder={`Add ${f.label.toLowerCase()}`}
1435
- rows={1}
1436
- // field-sizing grows the box to fit long/wrapped text
1437
- className={cn(
1438
- "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",
1439
- errors.has(f.key)
1440
- ? "border-destructive focus-visible:ring-destructive"
1441
- : "border-input focus-visible:ring-ring",
1442
- )}
1443
- />
1444
- ) : (
1445
- <span className="block whitespace-pre-wrap break-words px-2 pt-1.5">
1446
- {String(draft[f.key as keyof T] ?? "") || (
1447
- <span className="text-muted-foreground">—</span>
1448
- )}
1449
- </span>
1450
- )}
1451
- </dd>
1452
- </div>
1453
- ))}
1454
- </dl>
1455
- </section>
1456
- );
1457
- })}
1458
- </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>
1459
1682
 
1460
- {/* Footer — View shows Close/Edit; Edit shows Cancel/Save. */}
1461
- <div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
1462
- {readOnly ? (
1463
- <>
1464
- <Button onClick={() => requestClose(onCancel)}>
1465
- <X className="size-4" />
1466
- Close
1467
- </Button>
1468
- {onEdit && (
1469
- <Button variant="primary" onClick={onEdit}>
1470
- <Pencil className="size-4" />
1471
- Edit
1472
- </Button>
1473
- )}
1474
- </>
1475
- ) : (
1476
- <>
1477
- <Button onClick={() => requestClose(onCancel)}>
1478
- <X className="size-4" />
1479
- Cancel
1480
- </Button>
1481
- <Button variant="primary" onClick={handleSave}>
1482
- <Check className="size-4" />
1483
- Save
1484
- </Button>
1485
- </>
1486
- )}
1487
- </div>
1683
+ {formFooter}
1488
1684
  </aside>
1489
1685
  </>
1490
1686
  );
1491
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);