torch-glare 2.5.1 → 2.5.3
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/DataViews/data-views.tsx +1 -1
- package/apps/lib/components/DataViews/filters/filters.tsx +8 -12
- package/apps/lib/components/DataViews/header.tsx +5 -2
- package/apps/lib/components/DataViews/panel/section.tsx +3 -1
- package/apps/lib/components/DataViews/views/board-view.tsx +3 -1
- package/apps/lib/components/DataViews/views/inbox-view.tsx +4 -1
- package/apps/lib/components/DataViews/views/pane-views.tsx +5 -1
- package/apps/lib/components/DataViews/views/table-view.tsx +34 -4
- package/apps/lib/components/DataViews/views/tree-view.tsx +5 -3
- package/apps/lib/components/FormBuilder/context.ts +17 -26
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +1 -1
- package/apps/lib/components/FormBuilder/form-builder.tsx +32 -172
- package/apps/lib/components/FormBuilder/index.ts +0 -4
- package/apps/lib/components/FormBuilder/submit.tsx +11 -2
- package/apps/lib/components/FormBuilder/types.ts +10 -16
- package/apps/lib/components/FormRenderer/FormDrawer.tsx +2 -2
- package/apps/lib/components/FormRenderer/detail.tsx +6 -6
- package/apps/lib/components/FormRenderer/form-renderer.tsx +167 -35
- package/apps/lib/components/{FormBuilder → FormRenderer}/header.tsx +12 -25
- package/apps/lib/components/FormRenderer/index.ts +4 -0
- package/apps/lib/components/FormRenderer/section.tsx +39 -0
- package/apps/lib/components/{FormBuilder → FormRenderer}/stepper.tsx +65 -22
- package/apps/lib/components/FormRenderer/types.ts +7 -7
- package/apps/lib/components/SectionBlock.tsx +11 -1
- package/apps/lib/components/Table.tsx +54 -29
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +61 -61
- package/apps/lib/registry.json +5 -3
- package/apps/lib/tsconfig.tsbuildinfo +1 -1
- package/docs/components/data-views/index.md +7 -0
- package/docs/components/form-builder.md +65 -89
- package/docs/components/form-renderer.md +62 -26
- package/docs/components/form-summary.md +18 -3
- package/docs/components/section-block.md +1 -1
- package/docs/components/table.md +33 -0
- package/docs/how-to/forms-with-form-builder.md +47 -42
- package/docs/migration/changelog.md +3 -0
- package/docs/migration/form-builder-2.5.2.md +113 -0
- package/package.json +1 -1
|
@@ -311,7 +311,7 @@ function DataViewsRoot({
|
|
|
311
311
|
inherits it rather than landing on the black shell.
|
|
312
312
|
|
|
313
313
|
No border: Figma draws one, but every view already brings its own edge — the
|
|
314
|
-
table
|
|
314
|
+
table and the split views each draw their own panel border — so it only ever
|
|
315
315
|
read as a second outline around the first. */}
|
|
316
316
|
<div className="bg-background-presentation-form-base flex flex-1 overflow-hidden rounded-[16px]">
|
|
317
317
|
{/* Clip the scrollable surface to the parent radius minus its 1px border
|
|
@@ -95,12 +95,17 @@ function FiltersRoot({
|
|
|
95
95
|
) : (
|
|
96
96
|
// Reading the colour from a token rather than a `text-white` literal is what lets
|
|
97
97
|
// the same controls sit in the dark rail and in the light content area.
|
|
98
|
-
<h3 className="text-content-presentation-global-primary text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
|
|
98
|
+
<h3 className="text-content-presentation-global-primary min-w-0 truncate text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
|
|
99
99
|
{title}
|
|
100
100
|
</h3>
|
|
101
101
|
))}
|
|
102
102
|
{active && (
|
|
103
|
-
<Button
|
|
103
|
+
<Button
|
|
104
|
+
size="S"
|
|
105
|
+
variant="BorderStyle"
|
|
106
|
+
className="ms-auto"
|
|
107
|
+
onClick={() => setFilters({})}
|
|
108
|
+
>
|
|
104
109
|
{clearLabel}
|
|
105
110
|
</Button>
|
|
106
111
|
)}
|
|
@@ -117,15 +122,7 @@ function FiltersRoot({
|
|
|
117
122
|
|
|
118
123
|
{/* `onSubmit` is required by FormBuilder but never reached — there is no submit button and
|
|
119
124
|
no Enter target. Filters emit as you touch them. */}
|
|
120
|
-
|
|
121
|
-
centres the fields at 1100px behind 48px gutters, which in a 260px rail leaves the
|
|
122
|
-
controls less room than their own minimum width and overflows them. */}
|
|
123
|
-
<FormBuilder
|
|
124
|
-
onSubmit={() => {}}
|
|
125
|
-
values={values}
|
|
126
|
-
fieldDirection="vertical"
|
|
127
|
-
layout="bare"
|
|
128
|
-
>
|
|
125
|
+
<FormBuilder onSubmit={() => {}} values={values} fieldDirection="vertical">
|
|
129
126
|
<Sync fields={fields} />
|
|
130
127
|
<CellContext.Provider value="bare">
|
|
131
128
|
{/* Same fold as `Panel.Section`: a 0fr→1fr grid row, and `inert` so a collapsed
|
|
@@ -151,7 +148,6 @@ function FiltersRoot({
|
|
|
151
148
|
);
|
|
152
149
|
}
|
|
153
150
|
|
|
154
|
-
|
|
155
151
|
/**
|
|
156
152
|
* The filter surface, assembled in one place. `Presets` and `Custom` are ours because FormBuilder
|
|
157
153
|
* has no field for them; everything else you write is a FormBuilder field.
|
|
@@ -44,8 +44,11 @@ export function Header({ title, children, className }: HeaderProps) {
|
|
|
44
44
|
>
|
|
45
45
|
{title !== undefined && (
|
|
46
46
|
<>
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
{/* `min-w-0`, not `shrink-0`: the pill has to be able to give up width, or a long title
|
|
48
|
+
pushes the view switch, actions and panel toggle off the end of the bar. It yields
|
|
49
|
+
first and the title ellipsises instead. */}
|
|
50
|
+
<div className="flex h-9 min-w-0 items-center gap-2 rounded-[12px] border border-[#434446] bg-[#252729] px-[10px]">
|
|
51
|
+
<span className="truncate text-[28px] font-[510] uppercase leading-[1.19] text-white">
|
|
49
52
|
{title}
|
|
50
53
|
</span>
|
|
51
54
|
</div>
|
|
@@ -40,7 +40,9 @@ export function Section({
|
|
|
40
40
|
aria-controls={bodyId}
|
|
41
41
|
/>
|
|
42
42
|
) : (
|
|
43
|
-
|
|
43
|
+
// No `min-w-0` needed here, unlike the other headings: the parent is `flex flex-col`,
|
|
44
|
+
// so this already stretches to the column's width rather than sizing to its text.
|
|
45
|
+
<h3 className="text-content-presentation-global-primary truncate text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
|
|
44
46
|
{title}
|
|
45
47
|
</h3>
|
|
46
48
|
))}
|
|
@@ -356,7 +356,9 @@ function ColumnHeader({
|
|
|
356
356
|
COLUMN_BG[group.color ?? "gray"],
|
|
357
357
|
)}
|
|
358
358
|
>
|
|
359
|
-
|
|
359
|
+
{/* `min-w-0` because this is a flex item: without it the heading refuses to shrink below its
|
|
360
|
+
text and a long group name pushes the action button out of the column. */}
|
|
361
|
+
<h3 className="typography-headers-small-medium text-content-presentation-global-primary-light min-w-0 truncate">
|
|
360
362
|
{group.label}
|
|
361
363
|
</h3>
|
|
362
364
|
{onAction && (
|
|
@@ -67,9 +67,12 @@ function InboxViewImpl({
|
|
|
67
67
|
)}
|
|
68
68
|
>
|
|
69
69
|
<li className="border-border-presentation-global-primary border-b px-3 py-2">
|
|
70
|
+
{/* `block` before `truncate`: the parent is a plain `<li>`, so this span would otherwise
|
|
71
|
+
be inline — and `overflow` has no effect on an inline box, which would leave the
|
|
72
|
+
ellipsis silently doing nothing. */}
|
|
70
73
|
<span
|
|
71
74
|
style={{ fontFeatureSettings: "'cv05' on" }}
|
|
72
|
-
className="typography-display-medium-medium text-content-presentation-global-primary uppercase"
|
|
75
|
+
className="typography-display-medium-medium text-content-presentation-global-primary block truncate uppercase"
|
|
73
76
|
>
|
|
74
77
|
{titleField?.label ?? "inbox"}
|
|
75
78
|
</span>
|
|
@@ -134,7 +134,11 @@ function useIsActive(element: { value?: string }, defaultValue: string) {
|
|
|
134
134
|
export const PaneTable = markPaneView(function PaneTable(props: TreePaneTableProps) {
|
|
135
135
|
const active = useIsActive({ value: props.value }, "table");
|
|
136
136
|
if (!active) return null;
|
|
137
|
-
|
|
137
|
+
// Standalone, `TableView` draws its own border and radius so it reads as a separated surface.
|
|
138
|
+
// In here it is not standalone: the pane around it already draws exactly that border and radius
|
|
139
|
+
// (`tree-view.tsx`), so leaving the table's on nests one inside the other a pixel apart.
|
|
140
|
+
// `TreePaneTableProps` omits `className`, so there is no caller value to merge with.
|
|
141
|
+
return <TableView {...withoutTabProps(props)} className="rounded-none border-0" />;
|
|
138
142
|
}, { defaultValue: "table", defaultLabel: "List", defaultIcon: <Table2 /> });
|
|
139
143
|
|
|
140
144
|
/**
|
|
@@ -115,7 +115,15 @@ function TableViewImpl({
|
|
|
115
115
|
|
|
116
116
|
return (
|
|
117
117
|
<DndContext {...contextProps}>
|
|
118
|
-
|
|
118
|
+
{/* Its own edge, matching the inbox and tree panels: this view is a surface on the shell,
|
|
119
|
+
not a fill of it. `overflow-hidden` keeps the rows clipped to the radius. */}
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
"bg-background-presentation-form-base flex h-full overflow-hidden",
|
|
123
|
+
"border-border-presentation-global-primary rounded-[16px] border",
|
|
124
|
+
className,
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
119
127
|
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
|
120
128
|
<div
|
|
121
129
|
ref={scrollRef}
|
|
@@ -125,12 +133,30 @@ function TableViewImpl({
|
|
|
125
133
|
className="min-w-0 flex-1 overflow-auto rounded-lg"
|
|
126
134
|
>
|
|
127
135
|
<Table ref={tableRef} className="w-full">
|
|
128
|
-
|
|
136
|
+
{/* The header sticks to the scroller above by default — that lives on `TableHeader`
|
|
137
|
+
in the primitive. Two things this view adds:
|
|
138
|
+
|
|
139
|
+
Opacity. The primitive's header token is translucent, so scrolling rows read
|
|
140
|
+
straight through it; the primitive can't fix that without naming a surface colour
|
|
141
|
+
it doesn't know. Here the surface *is* known — the view root above sets
|
|
142
|
+
`form-base` — so paint that as the background-color (tailwind-merge drops the
|
|
143
|
+
primitive's translucent one, same `bg-color` group) and re-apply the tint as a
|
|
144
|
+
background-image, which stacks above background-color. Composited that is the
|
|
145
|
+
exact colour the header already had, just no longer see-through.
|
|
146
|
+
|
|
147
|
+
And `shadow-none`, because the drop shadow reads as a seam now that the view
|
|
148
|
+
carries its own border. */}
|
|
149
|
+
<TableHeader className="bg-background-presentation-form-base bg-[image:linear-gradient(var(--background-presentation-form-header),var(--background-presentation-form-header))] shadow-none">
|
|
129
150
|
<TableRow>
|
|
130
151
|
{onRowMove && <TableHead isDummy className="w-8" />}
|
|
131
152
|
{selectable && (
|
|
132
153
|
<TableHead isDummy className="w-12">
|
|
154
|
+
{/* Size is stated on both this and the per-row checkbox rather than left to
|
|
155
|
+
`Checkbox`'s default — they have to agree, and relying on the default on
|
|
156
|
+
one side only is what previously made the select-all bigger than the
|
|
157
|
+
column it heads. */}
|
|
133
158
|
<Checkbox
|
|
159
|
+
size="M"
|
|
134
160
|
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
135
161
|
onCheckedChange={toggleAll}
|
|
136
162
|
aria-label="Select all rows"
|
|
@@ -149,7 +175,11 @@ function TableViewImpl({
|
|
|
149
175
|
// so a screen reader hears one identical button per column.
|
|
150
176
|
sortLabel={field.label ?? formatPathLabel(field.path)}
|
|
151
177
|
>
|
|
152
|
-
{
|
|
178
|
+
{/* Wrapped rather than handed to `Table` as bare text: `truncate` needs a box
|
|
179
|
+
to clip, and the label's parent in the primitive is already `flex min-w-0`
|
|
180
|
+
so this span shrinks and ellipsises instead of wrapping the header row
|
|
181
|
+
onto a second line. */}
|
|
182
|
+
<span className="truncate">{field.label ?? field.path}</span>
|
|
153
183
|
</TableHead>
|
|
154
184
|
))}
|
|
155
185
|
</TableRow>
|
|
@@ -203,7 +233,7 @@ function TableViewImpl({
|
|
|
203
233
|
attributes, so it cannot express a controlled checkbox. */}
|
|
204
234
|
<div className="flex items-center justify-center">
|
|
205
235
|
<Checkbox
|
|
206
|
-
size="
|
|
236
|
+
size="M"
|
|
207
237
|
checked={selected}
|
|
208
238
|
onCheckedChange={() => toggleRow(id)}
|
|
209
239
|
aria-label="Select row"
|
|
@@ -253,8 +253,8 @@ function TreeViewImpl({
|
|
|
253
253
|
|
|
254
254
|
return (
|
|
255
255
|
// A split view, so the gap between the rail and the pane is part of the design: it paints the
|
|
256
|
-
// shell's black over the Master Container's surface, and the two cards float on it. The
|
|
257
|
-
//
|
|
256
|
+
// shell's black over the Master Container's surface, and the two cards float on it. The board
|
|
257
|
+
// fills its container instead — the table now carries its own border, like these panels.
|
|
258
258
|
<div className={cn("flex h-full gap-2 bg-black", className)}>
|
|
259
259
|
<div
|
|
260
260
|
className={cn(
|
|
@@ -265,9 +265,11 @@ function TreeViewImpl({
|
|
|
265
265
|
)}
|
|
266
266
|
>
|
|
267
267
|
<div className="border-border-presentation-global-primary border-b px-3 py-2">
|
|
268
|
+
{/* `block` before `truncate` — the parent is a plain div, so an inline span would clip
|
|
269
|
+
nothing. Same reason as the inbox's panel header. */}
|
|
268
270
|
<span
|
|
269
271
|
style={{ fontFeatureSettings: "'cv05' on" }}
|
|
270
|
-
className="typography-display-medium-medium text-content-presentation-global-primary uppercase"
|
|
272
|
+
className="typography-display-medium-medium text-content-presentation-global-primary block truncate uppercase"
|
|
271
273
|
>
|
|
272
274
|
{labelField?.label ?? "categories"}
|
|
273
275
|
</span>
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext } from "react";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* How a field row lays out its label against its control.
|
|
7
|
+
*
|
|
8
|
+
* `"flexible"` is the responsive one: stacked, then label-beside-control once the field row itself
|
|
9
|
+
* is past the container `md` breakpoint. It is what you get by leaving `fieldDirection` unset — and
|
|
10
|
+
* it is assignable so you can ask for it back where something else defaults you away from it, most
|
|
11
|
+
* notably a `FormRenderer` drawer, which pins `"vertical"`.
|
|
12
|
+
*/
|
|
13
|
+
export type FieldDirection = "horizontal" | "vertical" | "flexible";
|
|
6
14
|
|
|
7
15
|
/** Loading flag — drives the Submit spinner and disables inputs. */
|
|
8
16
|
export const LoadingContext = createContext<boolean>(false);
|
|
@@ -48,9 +56,14 @@ export const useBare = () => useContext(CellContext) !== false;
|
|
|
48
56
|
export const useOnTable = () => useContext(CellContext) === "table";
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
|
-
* Step registry — a `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
59
|
+
* Step registry — a `FormRenderer.Step` provides this so the fields rendered inside it can
|
|
60
|
+
* register their `name`, and the stepper validates just those names before advancing. `null`
|
|
61
|
+
* when not inside a stepper.
|
|
62
|
+
*
|
|
63
|
+
* The stepper itself lives in `FormRenderer` — it is chrome. This registry stays here because
|
|
64
|
+
* the *fields* are what register into it (see `fields/FieldShell.tsx`), and FormBuilder must
|
|
65
|
+
* stand alone without FormRenderer installed. FormRenderer's `StepSlot` imports it from here;
|
|
66
|
+
* the dependency never points the other way.
|
|
54
67
|
*/
|
|
55
68
|
export interface StepRegistry {
|
|
56
69
|
register: (name: string) => void;
|
|
@@ -58,25 +71,3 @@ export interface StepRegistry {
|
|
|
58
71
|
}
|
|
59
72
|
export const StepContext = createContext<StepRegistry | null>(null);
|
|
60
73
|
export const useStepRegistry = () => useContext(StepContext);
|
|
61
|
-
|
|
62
|
-
/** Stepper state shared by the nav + Back/Next/Submit buttons. */
|
|
63
|
-
export interface StepperContextValue {
|
|
64
|
-
currentStep: number;
|
|
65
|
-
totalSteps: number;
|
|
66
|
-
titles: string[];
|
|
67
|
-
isFirstStep: boolean;
|
|
68
|
-
isLastStep: boolean;
|
|
69
|
-
goToNext: () => void | Promise<void>;
|
|
70
|
-
goToPrevious: () => void;
|
|
71
|
-
goToStep: (index: number) => void;
|
|
72
|
-
/** Field names registered per step, for per-step validation. */
|
|
73
|
-
stepFields: Record<number, Set<string>>;
|
|
74
|
-
/** Steps that have passed validation — stay checked even after navigating back. */
|
|
75
|
-
completedSteps: Set<number>;
|
|
76
|
-
}
|
|
77
|
-
export const StepperContext = createContext<StepperContextValue | null>(null);
|
|
78
|
-
export const useStepper = () => {
|
|
79
|
-
const ctx = useContext(StepperContext);
|
|
80
|
-
if (!ctx) throw new Error("FormBuilder.Step/Back/Next must be used within FormBuilder.Stepper");
|
|
81
|
-
return ctx;
|
|
82
|
-
};
|
|
@@ -36,7 +36,7 @@ export interface FieldShellProps {
|
|
|
36
36
|
/**
|
|
37
37
|
* Shared wrapper for every `FormBuilder.*` field: the FieldSection row + the RHF
|
|
38
38
|
* `FormField`/`FormItem`/`FormControl`/`FormMessage` scaffolding. Also registers the
|
|
39
|
-
* field name into the enclosing `
|
|
39
|
+
* field name into the enclosing `FormRenderer.Step` (if any) so the stepper can
|
|
40
40
|
* validate per step.
|
|
41
41
|
*/
|
|
42
42
|
export function FieldShell({
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import { ReactNode } from "react";
|
|
5
3
|
import { useForm, type FieldValues } from "react-hook-form";
|
|
6
4
|
|
|
7
5
|
import { cn } from "../../utils/cn";
|
|
8
6
|
import { Form } from "../Form";
|
|
9
|
-
import {
|
|
10
|
-
import { LoadingContext, DirectionContext, StepperContext, FormIdContext } from "./context";
|
|
7
|
+
import { LoadingContext, DirectionContext, FormIdContext } from "./context";
|
|
11
8
|
import { markFieldKind } from "./field-kind";
|
|
12
|
-
import { Header } from "./header";
|
|
13
9
|
import type { FormBuilderRootProps } from "./types";
|
|
14
10
|
import {
|
|
15
11
|
TextField,
|
|
@@ -39,52 +35,8 @@ import {
|
|
|
39
35
|
RichTextField,
|
|
40
36
|
CustomField,
|
|
41
37
|
} from "./fields";
|
|
42
|
-
import {
|
|
43
|
-
Stepper,
|
|
44
|
-
Step,
|
|
45
|
-
Back,
|
|
46
|
-
Next,
|
|
47
|
-
StepperNav,
|
|
48
|
-
StepSlot,
|
|
49
|
-
useStepperState,
|
|
50
|
-
isStepElement,
|
|
51
|
-
isStepperElement,
|
|
52
|
-
} from "./stepper";
|
|
53
38
|
import { SubmitButton } from "./submit";
|
|
54
39
|
|
|
55
|
-
// ─── Section ─────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
export interface SectionProps {
|
|
58
|
-
title?: ReactNode;
|
|
59
|
-
color?: SectionColor;
|
|
60
|
-
icon?: ReactNode;
|
|
61
|
-
/**
|
|
62
|
-
* `"Table"` switches to the full-bleed table shell — no body padding, a rule under
|
|
63
|
-
* the header, and the card clipped to its radius. `FormBuilder.Table` uses it; pass
|
|
64
|
-
* it here only when hand-composing a table inside a section.
|
|
65
|
-
*/
|
|
66
|
-
variant?: SectionVariant;
|
|
67
|
-
/** Right-aligned content on the title row — e.g. action buttons. */
|
|
68
|
-
action?: ReactNode;
|
|
69
|
-
children: ReactNode;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** `FormBuilder.Section` — a titled Glare SectionBlock grouping fields. */
|
|
73
|
-
function Section({ title, color, icon, variant, action, children }: SectionProps) {
|
|
74
|
-
return (
|
|
75
|
-
<SectionBlock title={title} color={color} icon={icon} variant={variant} action={action}>
|
|
76
|
-
{children}
|
|
77
|
-
</SectionBlock>
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function isHeaderElement(node: React.ReactNode): node is React.ReactElement {
|
|
82
|
-
return (
|
|
83
|
-
React.isValidElement(node) &&
|
|
84
|
-
(node.type as { __isFormHeader?: boolean })?.__isFormHeader === true
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
40
|
// ─── Root ────────────────────────────────────────────────────────────────────
|
|
89
41
|
|
|
90
42
|
function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
@@ -98,9 +50,7 @@ function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
|
98
50
|
values,
|
|
99
51
|
loading = false,
|
|
100
52
|
fieldDirection,
|
|
101
|
-
layout = "page",
|
|
102
53
|
resetOnSuccess,
|
|
103
|
-
conclusion,
|
|
104
54
|
className,
|
|
105
55
|
}: FormBuilderRootProps<T>) {
|
|
106
56
|
// Hooks can't be conditional, so always create one; `formProp` wins when given
|
|
@@ -117,116 +67,25 @@ function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
|
117
67
|
if (resetOnSuccess) form.reset();
|
|
118
68
|
};
|
|
119
69
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const stepChildren = stepperEl ? React.Children.toArray(stepperEl.props.children) : [];
|
|
127
|
-
const steps = stepChildren.filter(isStepElement);
|
|
128
|
-
const stepExtras = stepChildren.filter((n) => !isStepElement(n));
|
|
129
|
-
const isStepper = !!stepperEl;
|
|
130
|
-
|
|
131
|
-
// Stepper state is lifted HERE so the nav can live in its own grid column, outside the
|
|
132
|
-
// `<form>`. Called unconditionally (inert when there are no steps) to keep hooks order stable.
|
|
133
|
-
const stepper = useStepperState(steps, form.trigger as Parameters<typeof useStepperState>[1]);
|
|
134
|
-
|
|
135
|
-
// The stepper nav is its own grid column beside the fields, inside the scrolling body.
|
|
136
|
-
const nav = isStepper ? <StepperNav /> : null;
|
|
137
|
-
|
|
138
|
-
// The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras like Back/Next),
|
|
139
|
-
// or the plain children. The Submit itself lives outside the form (see FormRenderer).
|
|
140
|
-
const fields = isStepper ? (
|
|
141
|
-
<>
|
|
142
|
-
{steps.map((step, i) => (
|
|
143
|
-
<StepSlot key={i} index={i} active={i === stepper.currentStep}>
|
|
144
|
-
{step.props.children}
|
|
145
|
-
</StepSlot>
|
|
146
|
-
))}
|
|
147
|
-
{stepExtras}
|
|
148
|
-
</>
|
|
149
|
-
) : (
|
|
150
|
-
rest
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
// The fields column caps at 1100px and centers — as the middle column of the grid, and
|
|
154
|
-
// standalone. `layout="bare"` drops the cap and the gutters so an embedded form fills its
|
|
155
|
-
// container instead: 48px of padding a side is most of a settings rail.
|
|
156
|
-
const fieldsInner = (
|
|
157
|
-
<div
|
|
158
|
-
className={cn(
|
|
159
|
-
"flex w-full flex-col gap-4",
|
|
160
|
-
layout === "page" && "mx-auto max-w-[1100px] px-[48px]",
|
|
161
|
-
)}
|
|
162
|
-
>
|
|
163
|
-
{fields}
|
|
164
|
-
</div>
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
const formEl = (
|
|
168
|
-
<form id={id} className="w-full min-w-0" onSubmit={form.handleSubmit(handleValid, onInvalid)}>
|
|
169
|
-
{fieldsInner}
|
|
170
|
-
</form>
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
// Inside the form surface: the stepper rail beside the fields. Columns never wrap — the
|
|
174
|
-
// layout stays side-by-side at every screen size (the fields column shrinks instead).
|
|
175
|
-
const bodyInner = nav ? (
|
|
176
|
-
<div className="grid w-full grid-cols-[1fr_minmax(0,1100px)_1fr] gap-8">
|
|
177
|
-
{nav}
|
|
178
|
-
{formEl}
|
|
179
|
-
{/* Empty third column — balances the rail's gutter so the middle column is centred. */}
|
|
180
|
-
<div />
|
|
181
|
-
</div>
|
|
182
|
-
) : (
|
|
183
|
-
formEl
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const surface = header ? (
|
|
187
|
-
// Scroll shell: the absolute header floats over the scrollable body. No fixed height — it
|
|
188
|
-
// fills whatever the parent gives it (like the drawer's panel fills its tray) and the body
|
|
189
|
-
// scrolls internally.
|
|
190
|
-
<div className="relative isolate flex h-full w-full flex-col overflow-hidden rounded-2xl bg-background-presentation-body-primary">
|
|
191
|
-
{header}
|
|
192
|
-
<div className="relative z-[1] flex min-h-0 w-full flex-1 flex-col overflow-y-auto px-6 py-6 pt-[72px] scrollbar-hide">
|
|
193
|
-
{bodyInner}
|
|
194
|
-
</div>
|
|
195
|
-
</div>
|
|
196
|
-
) : (
|
|
197
|
-
bodyInner
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
// The conclusion (right) lives OUTSIDE the scroll surface — its own panel beside it (mirroring the
|
|
201
|
-
// drawer's tray, a 6px gutter). Only the surface's body scrolls; the conclusion stays put.
|
|
202
|
-
const body = conclusion ? (
|
|
203
|
-
<div className="flex h-full flex-row items-stretch">
|
|
204
|
-
<div className="min-h-0 min-w-0 flex-1">{surface}</div>
|
|
205
|
-
<div className="ml-[6px] flex min-h-0">{conclusion}</div>
|
|
206
|
-
</div>
|
|
207
|
-
) : (
|
|
208
|
-
surface
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
// `className` lands on the OUTERMOST element — the one a parent lays out (e.g. `flex-1 min-h-0`
|
|
212
|
-
// to fill a flex column). `h-full` fills a parent that has a definite height.
|
|
213
|
-
const outerClassName = cn("h-full w-full @container", className);
|
|
214
|
-
|
|
215
|
-
const tree = (
|
|
216
|
-
<Form {...form}>
|
|
217
|
-
<div className={outerClassName}>{body}</div>
|
|
218
|
-
</Form>
|
|
219
|
-
);
|
|
220
|
-
|
|
70
|
+
// The `<form>` IS the outermost element — FormBuilder draws no frame of its own. `gap-4` is the
|
|
71
|
+
// spacing between sections, which is field structure; `@container` scopes any container query a
|
|
72
|
+
// field wants to the form's own width. Everything that used to wrap this — the page gutters, the
|
|
73
|
+
// scroll shell, the header, the stepper rail, the summary column — belongs to `FormRenderer`.
|
|
74
|
+
//
|
|
75
|
+
// `className` lands here, so a parent still lays the form out (e.g. `flex-1 min-h-0`).
|
|
221
76
|
return (
|
|
222
77
|
<FormIdContext.Provider value={id}>
|
|
223
78
|
<LoadingContext.Provider value={loading}>
|
|
224
79
|
<DirectionContext.Provider value={direction}>
|
|
225
|
-
{
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
80
|
+
<Form {...form}>
|
|
81
|
+
<form
|
|
82
|
+
id={id}
|
|
83
|
+
className={cn("flex w-full min-w-0 flex-col gap-4 @container", className)}
|
|
84
|
+
onSubmit={form.handleSubmit(handleValid, onInvalid)}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</form>
|
|
88
|
+
</Form>
|
|
230
89
|
</DirectionContext.Provider>
|
|
231
90
|
</LoadingContext.Provider>
|
|
232
91
|
</FormIdContext.Provider>
|
|
@@ -238,16 +97,24 @@ function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
|
238
97
|
*
|
|
239
98
|
* ```tsx
|
|
240
99
|
* <FormBuilder onSubmit={fn} resolver={r} defaultValues={d}>
|
|
241
|
-
* <FormBuilder.
|
|
242
|
-
*
|
|
243
|
-
* </FormBuilder.Section>
|
|
244
|
-
* <FormBuilder.Submit>Save</FormBuilder.Submit>
|
|
100
|
+
* <FormBuilder.Text name="name" label="Name" required />
|
|
101
|
+
* <FormBuilder.Select name="category" label="Category" options={CATEGORY} />
|
|
245
102
|
* </FormBuilder>
|
|
246
103
|
* ```
|
|
247
104
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
105
|
+
* **FormBuilder is the fields and nothing else** — the `<form>`, its react-hook-form context and
|
|
106
|
+
* the field components. Each field draws its own `FieldSection` row (label, required marker,
|
|
107
|
+
* hint); beyond that FormBuilder draws no frame at all: no titled section cards, no page gutters,
|
|
108
|
+
* no scroll shell, no title header, no stepper rail, no summary column. Rendered bare it fills
|
|
109
|
+
* whatever you put it in, which is what an embedded form — a settings rail, a `DataViews` filter
|
|
110
|
+
* panel — wants.
|
|
111
|
+
*
|
|
112
|
+
* For a real page or drawer form, wrap it in `FormRenderer`, which owns all of that chrome:
|
|
113
|
+
* `display` (page vs drawer), `header`, `actions`, `summary`, `FormRenderer.Section` and the
|
|
114
|
+
* wizard (`FormRenderer.Stepper` + `FormRenderer.Step`).
|
|
115
|
+
*
|
|
116
|
+
* `FormBuilder.Submit` stays here — it is the form's own submit button, wired to the `<form>` by
|
|
117
|
+
* id so it works even when the chrome renders it outside the element.
|
|
251
118
|
*/
|
|
252
119
|
export const FormBuilder = Object.assign(FormBuilderRoot, {
|
|
253
120
|
// fields
|
|
@@ -284,15 +151,8 @@ export const FormBuilder = Object.assign(FormBuilderRoot, {
|
|
|
284
151
|
Image: (props: Parameters<typeof FileField>[0]) => <FileField {...props} image />,
|
|
285
152
|
RichText: RichTextField,
|
|
286
153
|
Custom: CustomField,
|
|
287
|
-
//
|
|
288
|
-
Section,
|
|
289
|
-
Header,
|
|
154
|
+
// the form's own submit button
|
|
290
155
|
Submit: SubmitButton,
|
|
291
|
-
// stepper
|
|
292
|
-
Stepper,
|
|
293
|
-
Step,
|
|
294
|
-
Back,
|
|
295
|
-
Next,
|
|
296
156
|
});
|
|
297
157
|
|
|
298
158
|
/**
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
export { FormBuilder } from "./form-builder";
|
|
2
|
-
export type { SectionProps } from "./form-builder";
|
|
3
2
|
export { fieldKindOf, markFieldKind } from "./field-kind";
|
|
4
|
-
export { FormHeaderBar } from "./header";
|
|
5
|
-
export type { HeaderProps, HeaderVariant, FormHeaderBarProps } from "./header";
|
|
6
3
|
export { RichTextField } from "../TextEditor/RichTextField";
|
|
7
|
-
export type { StepProps } from "./stepper";
|
|
8
4
|
export type { FieldDirection, CellMode } from "./context";
|
|
9
5
|
export type {
|
|
10
6
|
BaseFieldProps,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ReactNode } from "react";
|
|
4
4
|
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
5
6
|
import { Button } from "../Button";
|
|
6
7
|
import { useFormId, useLoading } from "./context";
|
|
7
8
|
|
|
@@ -28,9 +29,17 @@ export function SubmitButton({ children, className, loadingText, form }: SubmitB
|
|
|
28
29
|
// Defaults to the enclosing form's id, so a Save in the header (outside the `<form>`)
|
|
29
30
|
// still submits it via native form-association.
|
|
30
31
|
form={form ?? ctxFormId}
|
|
31
|
-
|
|
32
|
+
// The blue fill, because Save is the form's primary action and a primary action is blue.
|
|
33
|
+
// Deliberately not a prop: a form has one primary action, and letting each caller pick a
|
|
34
|
+
// variant is how the rule stops being a rule.
|
|
35
|
+
variant="BluColStyle"
|
|
32
36
|
is_loading={loading}
|
|
33
|
-
|
|
37
|
+
// `w-fit` because the FormBuilder root is a flex COLUMN: a direct child with `width: auto`
|
|
38
|
+
// inherits `align-items: stretch` and spans the whole form. Sections want that (SectionBlock
|
|
39
|
+
// sets its own `w-full`); a Save button does not. `w-fit` rather than `self-start` so the
|
|
40
|
+
// header action bar — a row with `items-center` — is untouched at every button size.
|
|
41
|
+
// Merged through `cn` so a caller can still opt into `className="w-full"` in a narrow drawer.
|
|
42
|
+
className={cn("w-fit", className)}
|
|
34
43
|
>
|
|
35
44
|
{loading ? (loadingText ?? children ?? "Saving…") : (children ?? "Save")}
|
|
36
45
|
</Button>
|
|
@@ -228,7 +228,7 @@ export interface TableColumn {
|
|
|
228
228
|
* `FormBuilder.Table` — an editable table field (built on RHF `useFieldArray`). Each row is a
|
|
229
229
|
* record; each column cell is any `FormBuilder.*` field. Supports per-row checkbox selection
|
|
230
230
|
* (+ select-all + bulk delete), drag-drop reordering, and add/remove rows. Renders inside a
|
|
231
|
-
* `SectionBlock`, so place it as a top-level child (not inside a `
|
|
231
|
+
* `SectionBlock`, so place it as a top-level child (not inside a `FormRenderer.Section`).
|
|
232
232
|
*/
|
|
233
233
|
export interface TableFieldProps {
|
|
234
234
|
name: string;
|
|
@@ -276,26 +276,20 @@ export interface FormBuilderRootProps<T extends FieldValues = FieldValues> {
|
|
|
276
276
|
values?: T;
|
|
277
277
|
/** Loading flag — Submit shows a spinner and inputs disable. */
|
|
278
278
|
loading?: boolean;
|
|
279
|
-
/** Field row direction. Defaults to horizontal (vertical inside a drawer). */
|
|
280
|
-
fieldDirection?: FieldDirection;
|
|
281
279
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* `"
|
|
285
|
-
* edges by 48px gutters.
|
|
286
|
-
*
|
|
287
|
-
* `"bare"` drops all three so the fields fill whatever they are placed inside. Use it when the
|
|
288
|
-
* form is embedded in something narrow — a 260px settings rail has no room to give up 96px to
|
|
289
|
-
* gutters, and its controls would overflow the panel rather than fill it.
|
|
280
|
+
* Field row direction. Unset means `"flexible"` — stacked, then label-beside-control once the
|
|
281
|
+
* field row passes the container `md` breakpoint. `FormRenderer` pins `"vertical"` in a drawer;
|
|
282
|
+
* pass `"flexible"` there to get the responsive layout back.
|
|
290
283
|
*/
|
|
291
|
-
|
|
284
|
+
fieldDirection?: FieldDirection;
|
|
292
285
|
/** Reset to defaults after a successful submit. */
|
|
293
286
|
resetOnSuccess?: boolean;
|
|
294
287
|
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
288
|
+
* Lands on the `<form>` element itself — FormBuilder renders nothing around it. Use it to let
|
|
289
|
+
* the form fill its parent (e.g. `"flex-1 min-h-0"` inside a flex column that has a height).
|
|
290
|
+
*
|
|
291
|
+
* There is no `layout` prop: the fields always fill their container. The page framing that
|
|
292
|
+
* used to live here — the 1100px cap and 48px gutters — belongs to `FormRenderer`.
|
|
298
293
|
*/
|
|
299
|
-
conclusion?: ReactNode;
|
|
300
294
|
className?: string;
|
|
301
295
|
}
|