torch-glare 2.4.3 → 2.4.5
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/apps/lib/components/FormBuilder/fields/PhoneField.tsx +25 -0
- package/apps/lib/components/FormBuilder/fields/TableField.tsx +79 -51
- package/apps/lib/components/FormBuilder/form-builder.tsx +18 -11
- package/apps/lib/components/FormRenderer/detail.tsx +207 -0
- package/apps/lib/components/FormRenderer/form-renderer.tsx +46 -3
- package/apps/lib/components/FormRenderer/index.ts +7 -0
- package/apps/lib/components/FormRenderer/types.ts +2 -1
- package/apps/lib/components/Input.tsx +3 -0
- package/apps/lib/components/SearchableTable.tsx +5 -4
- package/apps/lib/components/SectionBlock.tsx +58 -11
- package/apps/lib/components/Select.tsx +3 -1
- package/apps/lib/components/Table.tsx +265 -67
- package/apps/lib/registry.json +1 -1
- package/apps/lib/tsconfig.tsbuildinfo +1 -1
- package/docs/components/form-builder.md +19 -6
- package/docs/components/form-renderer.md +56 -1
- package/docs/components/section-block.md +78 -2
- package/docs/components/table.md +44 -7
- package/docs/how-to/forms-with-form-builder.md +58 -2
- package/package.json +1 -1
|
@@ -37,12 +37,37 @@ function splitPhone(value: unknown, defaultDial: string): { dial: string; number
|
|
|
37
37
|
* `FormBuilder.Phone` — country dial-code (every country) + number. The picker is a
|
|
38
38
|
* `SearchableSelect` keyed by the unique ISO code (dial codes repeat) — type to filter by
|
|
39
39
|
* country name or dial. The value is stored as a `"+<dial> <number>"` string.
|
|
40
|
+
*
|
|
41
|
+
* **Inside a `FormBuilder.Table` cell** this collapses to a single plain number input: the
|
|
42
|
+
* fixed-width picker imposed a ~155px floor on the column and left the number truncated,
|
|
43
|
+
* and two controls in one cell is not a table column. Add a separate column (e.g. a
|
|
44
|
+
* `FormBuilder.Select` of dial codes) when a table needs the country code.
|
|
40
45
|
*/
|
|
41
46
|
export function PhoneField(props: PhoneFieldProps) {
|
|
42
47
|
const loading = useLoading();
|
|
43
48
|
const cell = useCell();
|
|
44
49
|
const defaultDial = props.defaultCountry ?? "+964"; // Iraq
|
|
45
50
|
|
|
51
|
+
if (cell) {
|
|
52
|
+
return (
|
|
53
|
+
<FieldShell {...props}>
|
|
54
|
+
{(field) => (
|
|
55
|
+
<InputField
|
|
56
|
+
type="tel"
|
|
57
|
+
inputMode="tel"
|
|
58
|
+
value={typeof field.value === "string" ? field.value : ""}
|
|
59
|
+
onChange={(e) => field.onChange(e.target.value.replace(/[^\d\s+-]/g, ""))}
|
|
60
|
+
onBlur={field.onBlur}
|
|
61
|
+
placeholder={props.placeholder ?? "Phone number"}
|
|
62
|
+
disabled={props.disabled || loading}
|
|
63
|
+
onTable
|
|
64
|
+
className="w-full"
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</FieldShell>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
return (
|
|
47
72
|
<FieldShell {...props}>
|
|
48
73
|
{(field) => {
|
|
@@ -21,7 +21,16 @@ import {
|
|
|
21
21
|
|
|
22
22
|
import { cn } from "../../../utils/cn";
|
|
23
23
|
import { SectionBlock } from "../../SectionBlock";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
Table,
|
|
26
|
+
TableBody,
|
|
27
|
+
TableCell,
|
|
28
|
+
TableEndAction,
|
|
29
|
+
TableHead,
|
|
30
|
+
TableHeader,
|
|
31
|
+
TableRow,
|
|
32
|
+
TableScroller,
|
|
33
|
+
} from "../../Table";
|
|
25
34
|
import { Checkbox } from "../../Checkbox";
|
|
26
35
|
import { Button } from "../../Button";
|
|
27
36
|
import { CellContext, useLoading } from "../context";
|
|
@@ -50,6 +59,8 @@ export function TableField(props: TableFieldProps) {
|
|
|
50
59
|
|
|
51
60
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
52
61
|
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
|
|
62
|
+
// Drag-resized column widths, keyed by column index; falls back to `column.width`.
|
|
63
|
+
const [resized, setResized] = useState<Record<number, number>>({});
|
|
53
64
|
|
|
54
65
|
const sensors = useSensors(
|
|
55
66
|
useSensor(PointerSensor),
|
|
@@ -112,6 +123,25 @@ export function TableField(props: TableFieldProps) {
|
|
|
112
123
|
|
|
113
124
|
const totalCols = columns.length + (showHandle ? 1 : 0) + (showSelect ? 1 : 0);
|
|
114
125
|
|
|
126
|
+
// Under the browser's default `table-layout: auto`, a column's `width` is only a hint —
|
|
127
|
+
// content (an input's intrinsic size, a currency prefix) silently widens it, which is why
|
|
128
|
+
// a `width: 110` column used to render at 200-odd px. When every column declares a width
|
|
129
|
+
// we can switch to fixed layout and have them honoured exactly. Mixed/absent widths keep
|
|
130
|
+
// auto layout, since under fixed layout an unsized column would collapse to nothing.
|
|
131
|
+
//
|
|
132
|
+
// Fixed layout also needs a *definite* table width — with `width: auto` the browser sizes
|
|
133
|
+
// the table from content and then redistributes, throwing the declared widths (and any
|
|
134
|
+
// drag-resize) away. So the widths are owned here and totalled onto the table, which is
|
|
135
|
+
// what lets a drag grow the table instead of stealing space from the next column.
|
|
136
|
+
const DUMMY_COL_WIDTH = 40; // the design's onset / selector columns
|
|
137
|
+
const colWidth = (col: TableColumn, ci: number) => resized[ci] ?? col.width;
|
|
138
|
+
const fixedLayout = columns.length > 0 && columns.every((col, ci) => !!colWidth(col, ci));
|
|
139
|
+
const tableWidth = fixedLayout
|
|
140
|
+
? columns.reduce((sum, col, ci) => sum + (colWidth(col, ci) ?? 0), 0) +
|
|
141
|
+
(showHandle ? DUMMY_COL_WIDTH : 0) +
|
|
142
|
+
(showSelect ? DUMMY_COL_WIDTH : 0)
|
|
143
|
+
: undefined;
|
|
144
|
+
|
|
115
145
|
const rowCells = (rowName: string, index: number) => (
|
|
116
146
|
<>
|
|
117
147
|
{showSelect && (
|
|
@@ -126,11 +156,14 @@ export function TableField(props: TableFieldProps) {
|
|
|
126
156
|
{columns.map((col, ci) => (
|
|
127
157
|
<TableCell
|
|
128
158
|
key={ci}
|
|
129
|
-
style={col
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
style={colWidth(col, ci) ? { width: colWidth(col, ci) } : undefined}
|
|
160
|
+
// Column widths come from `col.width`, so opt out of TableCell's 200px floor —
|
|
161
|
+
// otherwise a narrow column (e.g. `width: 110`) silently renders at 200px.
|
|
162
|
+
minWidth={0}
|
|
163
|
+
// Every cell here holds a form control, and the right-edge fade washes out whatever
|
|
164
|
+
// sits at its end — a Select's chevron, a date picker's button.
|
|
165
|
+
fade={false}
|
|
166
|
+
childrenClassName={cn("min-w-0 w-full", col.align && ALIGN[col.align])}
|
|
134
167
|
>
|
|
135
168
|
<CellContext.Provider value={true}>{col.cell(rowName, index)}</CellContext.Provider>
|
|
136
169
|
</TableCell>
|
|
@@ -139,12 +172,19 @@ export function TableField(props: TableFieldProps) {
|
|
|
139
172
|
);
|
|
140
173
|
|
|
141
174
|
const table = (
|
|
142
|
-
|
|
175
|
+
// `min-w-full` so the grid still spans the card when the columns are narrower than it,
|
|
176
|
+
// while staying free to overflow (and scroll) when they aren't.
|
|
177
|
+
<Table
|
|
178
|
+
className={cn("min-w-full", fixedLayout && "[table-layout:fixed]")}
|
|
179
|
+
style={tableWidth ? { width: tableWidth } : undefined}
|
|
180
|
+
>
|
|
143
181
|
<TableHeader>
|
|
144
182
|
<TableRow>
|
|
145
|
-
{
|
|
183
|
+
{/* Fixed 40px, per the design — and so these unsized columns can't absorb the
|
|
184
|
+
table's slack width and push the data columns off-screen. */}
|
|
185
|
+
{showHandle && <TableHead isDummy style={{ width: DUMMY_COL_WIDTH }} />}
|
|
146
186
|
{showSelect && (
|
|
147
|
-
<TableHead isDummy>
|
|
187
|
+
<TableHead isDummy style={{ width: DUMMY_COL_WIDTH }}>
|
|
148
188
|
<Checkbox
|
|
149
189
|
size="S"
|
|
150
190
|
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
@@ -155,7 +195,11 @@ export function TableField(props: TableFieldProps) {
|
|
|
155
195
|
{columns.map((col, ci) => (
|
|
156
196
|
<TableHead
|
|
157
197
|
key={ci}
|
|
198
|
+
// Width goes on the <th> too, so header and body columns agree.
|
|
199
|
+
style={colWidth(col, ci) ? { width: colWidth(col, ci) } : undefined}
|
|
200
|
+
onResize={(w) => setResized((prev) => ({ ...prev, [ci]: w }))}
|
|
158
201
|
sortType={col.sortKey && sort?.key === col.sortKey ? sort.dir : undefined}
|
|
202
|
+
sortLabel={typeof col.header === "string" ? col.header : undefined}
|
|
159
203
|
onSort={col.sortKey ? () => applySort(col.sortKey as string) : undefined}
|
|
160
204
|
>
|
|
161
205
|
{col.header}
|
|
@@ -183,58 +227,40 @@ export function TableField(props: TableFieldProps) {
|
|
|
183
227
|
|
|
184
228
|
{fields.length === 0 && (
|
|
185
229
|
<TableRow>
|
|
186
|
-
<TableCell colSpan={totalCols}>
|
|
230
|
+
<TableCell colSpan={totalCols} minWidth={0}>
|
|
187
231
|
<span className="typography-body-small-regular text-content-presentation-global-secondary">
|
|
188
232
|
No rows yet.
|
|
189
233
|
</span>
|
|
190
234
|
</TableCell>
|
|
191
235
|
</TableRow>
|
|
192
236
|
)}
|
|
193
|
-
|
|
194
|
-
<TableRow className="h-[40px]">
|
|
195
|
-
<TableCell
|
|
196
|
-
colSpan={totalCols}
|
|
197
|
-
className="border-t-2 border-transparent hover:border-border-presentation-table-action-hover hover:bg-background-presentation-table-acton-hover"
|
|
198
|
-
>
|
|
199
|
-
<button
|
|
200
|
-
type="button"
|
|
201
|
-
disabled={loading}
|
|
202
|
-
onClick={() => append((props.defaultItem ?? {}) as never)}
|
|
203
|
-
className="flex w-full items-center justify-start gap-2 typography-body-medium-semibold text-content-presentation-action-light-primary [&_i]:text-[20px]"
|
|
204
|
-
>
|
|
205
|
-
<i className="ri-add-line" />
|
|
206
|
-
{props.addLabel ?? "Add New"}
|
|
207
|
-
</button>
|
|
208
|
-
</TableCell>
|
|
209
|
-
</TableRow>
|
|
210
237
|
</TableBody>
|
|
211
238
|
</Table>
|
|
212
239
|
);
|
|
213
240
|
|
|
214
241
|
// Header actions on the right of the SectionBlock title row: a "Delete Row" that's disabled
|
|
215
|
-
// until rows are selected, and a primary "Add New".
|
|
242
|
+
// until rows are selected, and a primary "Add New". They live outside the scroll container,
|
|
243
|
+
// so they stay put while the grid scrolls sideways.
|
|
216
244
|
const headerActions = (
|
|
217
245
|
<>
|
|
218
246
|
{selectable && (
|
|
219
247
|
<Button
|
|
220
248
|
type="button"
|
|
221
|
-
size="
|
|
249
|
+
size="M"
|
|
222
250
|
variant="BorderStyle"
|
|
223
251
|
disabled={loading || selected.size === 0}
|
|
224
252
|
onClick={deleteSelected}
|
|
225
253
|
>
|
|
226
|
-
<i className="ri-delete-bin-line" />
|
|
227
254
|
Delete Row
|
|
228
255
|
</Button>
|
|
229
256
|
)}
|
|
230
257
|
<Button
|
|
231
258
|
type="button"
|
|
232
|
-
size="
|
|
259
|
+
size="M"
|
|
233
260
|
variant="BluColStyle"
|
|
234
261
|
disabled={loading}
|
|
235
262
|
onClick={() => append((props.defaultItem ?? {}) as never)}
|
|
236
263
|
>
|
|
237
|
-
<i className="ri-add-line" />
|
|
238
264
|
{props.addLabel ?? "Add New"}
|
|
239
265
|
</Button>
|
|
240
266
|
</>
|
|
@@ -242,32 +268,34 @@ export function TableField(props: TableFieldProps) {
|
|
|
242
268
|
|
|
243
269
|
return (
|
|
244
270
|
<SectionBlock
|
|
271
|
+
variant="Table"
|
|
245
272
|
title={props.title}
|
|
246
273
|
color={props.color}
|
|
247
274
|
icon={props.icon}
|
|
248
275
|
action={headerActions}
|
|
249
|
-
bodyClassName="px-[16px]"
|
|
250
|
-
// The bottom "Add New" footer is the last element, so drop SectionBlock's default
|
|
251
|
-
// bottom padding to keep the table flush with the card edge (matches the Figma).
|
|
252
|
-
containerClassName="pb-0"
|
|
253
276
|
>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
277
|
+
{props.description && (
|
|
278
|
+
<p className="px-[16px] pt-[12px] typography-body-small-regular text-content-presentation-global-secondary">
|
|
279
|
+
{props.description}
|
|
280
|
+
</p>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
{/* The table is the only horizontally scrolling element … */}
|
|
284
|
+
<TableScroller>
|
|
285
|
+
{showHandle ? (
|
|
286
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
287
|
+
{table}
|
|
288
|
+
</DndContext>
|
|
289
|
+
) : (
|
|
290
|
+
table
|
|
259
291
|
)}
|
|
292
|
+
</TableScroller>
|
|
260
293
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
) : (
|
|
267
|
-
table
|
|
268
|
-
)}
|
|
269
|
-
</div>
|
|
270
|
-
</div>
|
|
294
|
+
{/* … so this stays visible no matter how far right the columns are scrolled. */}
|
|
295
|
+
<TableEndAction disabled={loading} onClick={() => append((props.defaultItem ?? {}) as never)}>
|
|
296
|
+
<i className="ri-add-line" />
|
|
297
|
+
{props.addLabel ?? "Add New"}
|
|
298
|
+
</TableEndAction>
|
|
271
299
|
</SectionBlock>
|
|
272
300
|
);
|
|
273
301
|
}
|
|
@@ -6,7 +6,7 @@ import { useForm, type FieldValues } from "react-hook-form";
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "../../utils/cn";
|
|
8
8
|
import { Form } from "../Form";
|
|
9
|
-
import { SectionBlock, type SectionColor } from "../SectionBlock";
|
|
9
|
+
import { SectionBlock, type SectionColor, type SectionVariant } from "../SectionBlock";
|
|
10
10
|
import { LoadingContext, DirectionContext, StepperContext, FormIdContext } from "./context";
|
|
11
11
|
import { Header } from "./header";
|
|
12
12
|
import type { FormBuilderRootProps } from "./types";
|
|
@@ -57,13 +57,21 @@ export interface SectionProps {
|
|
|
57
57
|
title?: ReactNode;
|
|
58
58
|
color?: SectionColor;
|
|
59
59
|
icon?: ReactNode;
|
|
60
|
+
/**
|
|
61
|
+
* `"Table"` switches to the full-bleed table shell — no body padding, a rule under
|
|
62
|
+
* the header, and the card clipped to its radius. `FormBuilder.Table` uses it; pass
|
|
63
|
+
* it here only when hand-composing a table inside a section.
|
|
64
|
+
*/
|
|
65
|
+
variant?: SectionVariant;
|
|
66
|
+
/** Right-aligned content on the title row — e.g. action buttons. */
|
|
67
|
+
action?: ReactNode;
|
|
60
68
|
children: ReactNode;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/** `FormBuilder.Section` — a titled Glare SectionBlock grouping fields. */
|
|
64
|
-
function Section({ title, color, icon, children }: SectionProps) {
|
|
72
|
+
function Section({ title, color, icon, variant, action, children }: SectionProps) {
|
|
65
73
|
return (
|
|
66
|
-
<SectionBlock title={title} color={color} icon={icon}>
|
|
74
|
+
<SectionBlock title={title} color={color} icon={icon} variant={variant} action={action}>
|
|
67
75
|
{children}
|
|
68
76
|
</SectionBlock>
|
|
69
77
|
);
|
|
@@ -122,11 +130,11 @@ function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
|
122
130
|
// `<form>`. Called unconditionally (inert when there are no steps) to keep hooks order stable.
|
|
123
131
|
const stepper = useStepperState(steps, form.trigger as Parameters<typeof useStepperState>[1]);
|
|
124
132
|
|
|
125
|
-
// The stepper nav is its own column beside the fields, inside the
|
|
133
|
+
// The stepper nav is its own grid column beside the fields, inside the scrolling body.
|
|
126
134
|
const nav = isStepper ? <StepperNav /> : null;
|
|
127
135
|
|
|
128
|
-
// The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras like
|
|
129
|
-
//
|
|
136
|
+
// The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras like Back/Next),
|
|
137
|
+
// or the plain children. The Submit itself lives outside the form (see FormRenderer).
|
|
130
138
|
const fields = isStepper ? (
|
|
131
139
|
<>
|
|
132
140
|
{steps.map((step, i) => (
|
|
@@ -179,13 +187,12 @@ function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
|
179
187
|
bodyInner
|
|
180
188
|
);
|
|
181
189
|
|
|
182
|
-
// The conclusion lives OUTSIDE the
|
|
183
|
-
// drawer's tray
|
|
184
|
-
// It never wraps below the form: the two stay side-by-side at every screen size.
|
|
190
|
+
// The conclusion (right) lives OUTSIDE the scroll surface — its own panel beside it (mirroring the
|
|
191
|
+
// drawer's tray, a 6px gutter). Only the surface's body scrolls; the conclusion stays put.
|
|
185
192
|
const body = conclusion ? (
|
|
186
|
-
<div className="flex h-full flex-row items-stretch
|
|
193
|
+
<div className="flex h-full flex-row items-stretch">
|
|
187
194
|
<div className="min-h-0 min-w-0 flex-1">{surface}</div>
|
|
188
|
-
<div className="flex min-h-0">{conclusion}</div>
|
|
195
|
+
<div className="ml-[6px] flex min-h-0">{conclusion}</div>
|
|
189
196
|
</div>
|
|
190
197
|
) : (
|
|
191
198
|
surface
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
7
|
+
import { formBarItemStyles } from "../TabFormItem";
|
|
8
|
+
import { FormHeaderBar, type HeaderVariant } from "../FormBuilder/header";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detail-tabs — a display-only view where a left **sidebar** switches the main area between
|
|
12
|
+
* **`FormBuilder.Section` panels** (a detail page, not a form). It lives on `FormRenderer`
|
|
13
|
+
* (`FormRenderer.Sidebar` / `.Sidebar.Item` / `.Tab`) so `FormBuilder` stays form-only. Built on
|
|
14
|
+
* the same Radix Tabs primitive shadcn uses: the sidebar is the `Tabs.List`, each `Sidebar.Item` a
|
|
15
|
+
* `Tabs.Trigger`, each `Tab` a `Tabs.Content` — so the fixed rail and the panels share tab state.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface DetailSidebarProps {
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `FormRenderer.Sidebar` — the tab rail, sitting where a `FormBuilder.Stepper`'s nav would.
|
|
24
|
+
* Renders **nothing itself**: the FormRenderer root detects it and places its `Sidebar.Item`
|
|
25
|
+
* children (the tab triggers) into the fixed rail.
|
|
26
|
+
*/
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- children are read by the FormRenderer root
|
|
28
|
+
function DetailSidebarRoot(_props: DetailSidebarProps) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
(DetailSidebarRoot as unknown as { __isDetailSidebar: boolean }).__isDetailSidebar = true;
|
|
32
|
+
|
|
33
|
+
export interface DetailSidebarItemProps {
|
|
34
|
+
/** Ties this row to the `FormRenderer.Tab` of the same `value` — clicking it shows that panel. */
|
|
35
|
+
value: string;
|
|
36
|
+
/** Leading icon (e.g. a Remix `<i className="ri-…" />`). */
|
|
37
|
+
icon?: React.ReactNode;
|
|
38
|
+
children: React.ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `FormRenderer.Sidebar.Item` — a rail nav row, rendered as a Radix `Tabs.Trigger` styled with the
|
|
43
|
+
* `TabFormItem` `tree` look. Radix drives the active state (`data-state="active"` → black pill).
|
|
44
|
+
*/
|
|
45
|
+
function DetailSidebarItem({ value, icon, children }: DetailSidebarItemProps) {
|
|
46
|
+
return (
|
|
47
|
+
<TabsPrimitive.Trigger
|
|
48
|
+
value={value}
|
|
49
|
+
className={cn(
|
|
50
|
+
formBarItemStyles({ componentType: "tree" }),
|
|
51
|
+
"h-[32px] w-full justify-start gap-2 rounded-[10px] px-[6px] [&_i]:text-[16px]",
|
|
52
|
+
// Radix sets `data-state` on the trigger; the active tab is the filled (black) pill.
|
|
53
|
+
"data-[state=active]:bg-background-presentation-tab-topbar-selected data-[state=active]:text-content-presentation-tab-action-selected",
|
|
54
|
+
"data-[state=active]:hover:bg-background-presentation-tab-topbar-selected data-[state=active]:hover:px-[6px]",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{icon}
|
|
58
|
+
<span className="flex-1 truncate text-left typography-body-medium-medium">{children}</span>
|
|
59
|
+
</TabsPrimitive.Trigger>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const DetailSidebar = Object.assign(DetailSidebarRoot, {
|
|
64
|
+
Item: DetailSidebarItem,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export function isDetailSidebarElement(
|
|
68
|
+
node: React.ReactNode,
|
|
69
|
+
): node is React.ReactElement<DetailSidebarProps> {
|
|
70
|
+
return (
|
|
71
|
+
React.isValidElement(node) &&
|
|
72
|
+
(node.type as { __isDetailSidebar?: boolean })?.__isDetailSidebar === true
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DetailRowProps {
|
|
77
|
+
label: React.ReactNode;
|
|
78
|
+
value: React.ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `FormRenderer.Row` — a read-only label/value cell for detail content (the display counterpart of a
|
|
83
|
+
* form field). Drop several inside a `FormRenderer.Grid` within a `FormBuilder.Section`.
|
|
84
|
+
*/
|
|
85
|
+
export function DetailRow({ label, value }: DetailRowProps) {
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex flex-col gap-1">
|
|
88
|
+
<span className="typography-body-small-regular text-content-presentation-global-secondary">
|
|
89
|
+
{label}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="typography-body-medium-medium text-content-presentation-global-primary">
|
|
92
|
+
{value}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DetailGridProps {
|
|
99
|
+
/** Column count (default 2). */
|
|
100
|
+
columns?: 1 | 2 | 3;
|
|
101
|
+
children: React.ReactNode;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const GRID_COLS: Record<NonNullable<DetailGridProps["columns"]>, string> = {
|
|
105
|
+
1: "grid-cols-1",
|
|
106
|
+
2: "grid-cols-2",
|
|
107
|
+
3: "grid-cols-3",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `FormRenderer.Grid` — arranges `FormRenderer.Row`s in an equal-column grid spanning the **full
|
|
112
|
+
* section width** (default 2 columns, so cells split in half). Padded so the rows breathe inside a
|
|
113
|
+
* `FormBuilder.Section` (clear of the title badge, roomy row + column spacing).
|
|
114
|
+
*/
|
|
115
|
+
export function DetailGrid({ columns = 2, children }: DetailGridProps) {
|
|
116
|
+
return (
|
|
117
|
+
<div className={cn("grid w-full gap-x-12 gap-y-5 py-3", GRID_COLS[columns])}>{children}</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface DetailTabProps {
|
|
122
|
+
/** Ties this panel to the `Sidebar.Item` of the same `value`. */
|
|
123
|
+
value: string;
|
|
124
|
+
/** The panel body — typically `FormBuilder.Section` display blocks. */
|
|
125
|
+
children: React.ReactNode;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `FormRenderer.Tab` — a content panel shown when its `value` is the active tab. Renders
|
|
130
|
+
* **nothing itself**: the FormRenderer root detects it and mounts it as a Radix `Tabs.Content`
|
|
131
|
+
* (kept mounted, inactive ones hidden).
|
|
132
|
+
*/
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- read by the FormRenderer root
|
|
134
|
+
export function DetailTab(_props: DetailTabProps) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
(DetailTab as unknown as { __isDetailTab: boolean }).__isDetailTab = true;
|
|
138
|
+
|
|
139
|
+
export function isDetailTabElement(
|
|
140
|
+
node: React.ReactNode,
|
|
141
|
+
): node is React.ReactElement<DetailTabProps> {
|
|
142
|
+
return (
|
|
143
|
+
React.isValidElement(node) && (node.type as { __isDetailTab?: boolean })?.__isDetailTab === true
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface DetailTabsViewProps {
|
|
148
|
+
header?: { title: string; label?: string; variant?: HeaderVariant };
|
|
149
|
+
/** Header action buttons (Print / Approve / …). */
|
|
150
|
+
actions?: React.ReactNode;
|
|
151
|
+
/** The `FormRenderer.Sidebar` element — its children are the tab triggers. */
|
|
152
|
+
sidebar: React.ReactElement<DetailSidebarProps>;
|
|
153
|
+
/** The `FormRenderer.Tab` elements — the content panels. */
|
|
154
|
+
tabs: React.ReactElement<DetailTabProps>[];
|
|
155
|
+
className?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The detail-tabs surface: the floating header over a fixed left rail (the sidebar) + a scrolling
|
|
160
|
+
* content column showing the active tab. Radix `Tabs.Root` owns the state (uncontrolled, defaults to
|
|
161
|
+
* the first tab). The rail matches the stepper's rail position; only the content column scrolls.
|
|
162
|
+
*/
|
|
163
|
+
export function DetailTabsView({ header, actions, sidebar, tabs, className }: DetailTabsViewProps) {
|
|
164
|
+
const defaultValue = tabs[0]?.props.value;
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<TabsPrimitive.Root
|
|
168
|
+
orientation="vertical"
|
|
169
|
+
defaultValue={defaultValue}
|
|
170
|
+
className={cn("h-full w-full @container", className)}
|
|
171
|
+
>
|
|
172
|
+
{/* Scroll shell — mirrors FormBuilder's: the absolute header floats over the body. */}
|
|
173
|
+
<div className="relative isolate flex h-full w-full flex-col overflow-hidden rounded-2xl bg-background-presentation-body-primary">
|
|
174
|
+
{header && (
|
|
175
|
+
<FormHeaderBar title={header.title} label={header.label} variant={header.variant}>
|
|
176
|
+
{actions}
|
|
177
|
+
</FormHeaderBar>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<div className="relative z-[1] flex min-h-0 w-full flex-1 flex-row">
|
|
181
|
+
{/* The fixed rail — the tab list. `pt-[72px]` clears the floating header. */}
|
|
182
|
+
<TabsPrimitive.List asChild>
|
|
183
|
+
<aside className="flex h-full w-[216px] shrink-0 flex-col gap-1 overflow-y-auto border-r border-border-presentation-global-primary bg-black-alpha-5 px-2 pb-6 pt-[72px] scrollbar-hide">
|
|
184
|
+
{sidebar.props.children}
|
|
185
|
+
</aside>
|
|
186
|
+
</TabsPrimitive.List>
|
|
187
|
+
|
|
188
|
+
{/* The only scrolling region — the active tab's Sections. */}
|
|
189
|
+
<div className="flex min-h-0 w-full flex-1 flex-col overflow-y-auto px-6 py-6 pt-[72px] scrollbar-hide">
|
|
190
|
+
<div className="mx-auto flex w-full max-w-[1100px] flex-col gap-4">
|
|
191
|
+
{tabs.map((panel) => (
|
|
192
|
+
<TabsPrimitive.Content
|
|
193
|
+
key={panel.props.value}
|
|
194
|
+
value={panel.props.value}
|
|
195
|
+
forceMount
|
|
196
|
+
className="flex flex-col gap-4 outline-none data-[state=inactive]:hidden"
|
|
197
|
+
>
|
|
198
|
+
{panel.props.children}
|
|
199
|
+
</TabsPrimitive.Content>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</TabsPrimitive.Root>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useId } from "react";
|
|
3
|
+
import { Children, useId } from "react";
|
|
4
4
|
import { FieldValues } from "react-hook-form";
|
|
5
5
|
|
|
6
6
|
import { FormBuilder } from "../FormBuilder";
|
|
7
7
|
import { FormIdContext, LoadingContext } from "../FormBuilder/context";
|
|
8
8
|
import { StepperActions } from "../FormBuilder/stepper";
|
|
9
|
+
import {
|
|
10
|
+
DetailSidebar,
|
|
11
|
+
DetailTab,
|
|
12
|
+
DetailGrid,
|
|
13
|
+
DetailRow,
|
|
14
|
+
DetailTabsView,
|
|
15
|
+
isDetailSidebarElement,
|
|
16
|
+
isDetailTabElement,
|
|
17
|
+
} from "./detail";
|
|
9
18
|
import { FormDrawer } from "./FormDrawer";
|
|
10
19
|
import type { FormRendererProps } from "./types";
|
|
11
20
|
|
|
@@ -17,8 +26,12 @@ import type { FormRendererProps } from "./types";
|
|
|
17
26
|
* FormRenderer never manufactures a Submit — you compose it and hand it to `actions`
|
|
18
27
|
* (`actions={<FormBuilder.Submit>Save</FormBuilder.Submit>}`). It renders in the form's
|
|
19
28
|
* header action pill (page) or the drawer header (drawer), and auto-targets this form.
|
|
29
|
+
*
|
|
30
|
+
* Give it `FormRenderer.Sidebar` + `FormRenderer.Tab` children instead of fields and it switches to
|
|
31
|
+
* a **detail-tabs** view: a display-only page (no `<form>`) whose sidebar swaps `FormBuilder.Section`
|
|
32
|
+
* panels — the sidebar sits where a stepper's rail would.
|
|
20
33
|
*/
|
|
21
|
-
|
|
34
|
+
function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
22
35
|
children,
|
|
23
36
|
id,
|
|
24
37
|
onSubmit,
|
|
@@ -52,11 +65,29 @@ export function FormRenderer<T extends FieldValues = FieldValues>({
|
|
|
52
65
|
const autoId = useId();
|
|
53
66
|
const formId = id ?? autoId;
|
|
54
67
|
|
|
68
|
+
// Detail-tabs mode: a `FormRenderer.Sidebar` + `FormRenderer.Tab` children mean a display-only
|
|
69
|
+
// detail page (no `<form>`) — the sidebar swaps Section panels via Radix Tabs. Detected here so
|
|
70
|
+
// the form props below are simply unused.
|
|
71
|
+
const childArray = Children.toArray(children);
|
|
72
|
+
const detailSidebar = childArray.find(isDetailSidebarElement);
|
|
73
|
+
const detailTabs = childArray.filter(isDetailTabElement);
|
|
74
|
+
if (detailSidebar && detailTabs.length > 0) {
|
|
75
|
+
return (
|
|
76
|
+
<DetailTabsView
|
|
77
|
+
header={header}
|
|
78
|
+
actions={actions}
|
|
79
|
+
sidebar={detailSidebar}
|
|
80
|
+
tabs={detailTabs}
|
|
81
|
+
className={className}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
const inner = (
|
|
56
87
|
<FormBuilder
|
|
57
88
|
id={formId}
|
|
58
89
|
form={form}
|
|
59
|
-
onSubmit={onSubmit}
|
|
90
|
+
onSubmit={onSubmit ?? (() => {})}
|
|
60
91
|
onInvalid={onInvalid}
|
|
61
92
|
resolver={resolver}
|
|
62
93
|
defaultValues={defaultValues}
|
|
@@ -109,3 +140,15 @@ export function FormRenderer<T extends FieldValues = FieldValues>({
|
|
|
109
140
|
// Page display: FormBuilder already placed `summary` as the grid's conclusion column.
|
|
110
141
|
return inner;
|
|
111
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* FormRenderer — see {@link FormRendererRoot}. The compound statics drive the display-only
|
|
146
|
+
* **detail-tabs** view: `FormRenderer.Sidebar` (the rail) + `FormRenderer.Sidebar.Item` (a tab) +
|
|
147
|
+
* `FormRenderer.Tab` (a `FormBuilder.Section` panel).
|
|
148
|
+
*/
|
|
149
|
+
export const FormRenderer = Object.assign(FormRendererRoot, {
|
|
150
|
+
Sidebar: DetailSidebar,
|
|
151
|
+
Tab: DetailTab,
|
|
152
|
+
Grid: DetailGrid,
|
|
153
|
+
Row: DetailRow,
|
|
154
|
+
});
|
|
@@ -2,3 +2,10 @@ export { FormRenderer } from "./form-renderer";
|
|
|
2
2
|
export { FormDrawer } from "./FormDrawer";
|
|
3
3
|
export type { FormDrawerProps } from "./FormDrawer";
|
|
4
4
|
export type { FormRendererProps, FormRendererDisplay, FieldDirection } from "./types";
|
|
5
|
+
export type {
|
|
6
|
+
DetailSidebarProps,
|
|
7
|
+
DetailSidebarItemProps,
|
|
8
|
+
DetailTabProps,
|
|
9
|
+
DetailRowProps,
|
|
10
|
+
DetailGridProps,
|
|
11
|
+
} from "./detail";
|
|
@@ -23,7 +23,8 @@ export interface FormRendererProps<T extends FieldValues = FieldValues> {
|
|
|
23
23
|
children: ReactNode;
|
|
24
24
|
|
|
25
25
|
// --- react-hook-form root (forwarded to FormBuilder) ---
|
|
26
|
-
|
|
26
|
+
/** Submit handler. Optional — a display-only detail-tabs view (`FormRenderer.Sidebar`) has no form. */
|
|
27
|
+
onSubmit?: (values: T) => void | Promise<void>;
|
|
27
28
|
onInvalid?: (errors: FieldErrors<T>) => void;
|
|
28
29
|
resolver?: Resolver<T>;
|
|
29
30
|
defaultValues?: DefaultValues<T>;
|
|
@@ -198,6 +198,9 @@ export const GroupStyles = cva(
|
|
|
198
198
|
"hover:caret-border-presentation-state-negative",
|
|
199
199
|
],
|
|
200
200
|
},
|
|
201
|
+
// Transparent border/background so the control blends into a table cell. The hover and
|
|
202
|
+
// focus shadows are deliberately left alone — a cell control lifts exactly like any
|
|
203
|
+
// other field.
|
|
201
204
|
onTable: {
|
|
202
205
|
true: ["border-transparent", "bg-transparent"],
|
|
203
206
|
},
|