@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 +1 -1
- package/src/breadcrumbs.tsx +97 -0
- package/src/record-view.tsx +342 -130
- 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
|
|
@@ -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-
|
|
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-
|
|
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-
|
|
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="
|
|
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.
|
|
1051
|
-
|
|
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="
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|