@viliha/vui-ui 1.0.11 → 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 +1 -1
- package/src/breadcrumbs.tsx +97 -0
- package/src/record-view.tsx +323 -116
- package/src/theme.css +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.0
|
|
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
|
+
}
|
package/src/record-view.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-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
|
+
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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);
|