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
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
DrawerNotchDivider,
|
|
13
13
|
DrawerNotchPill,
|
|
14
14
|
} from "../Drawer";
|
|
15
|
-
import { FormHeaderBar, type HeaderVariant } from "
|
|
15
|
+
import { FormHeaderBar, type HeaderVariant } from "./header";
|
|
16
16
|
|
|
17
17
|
export interface FormDrawerProps {
|
|
18
18
|
open: boolean;
|
|
@@ -108,7 +108,7 @@ export function FormDrawer({
|
|
|
108
108
|
|
|
109
109
|
{/* pt-[72px] clears the 44px header pill (inset 4px) — same as the page shell. The
|
|
110
110
|
48px bottom breathing-room goes on an inner wrapper, not the scroll container:
|
|
111
|
-
|
|
111
|
+
FormRenderer's outer element is `h-full`, which would pin it to the content box and
|
|
112
112
|
swallow the container's own `pb`. On a plain wrapper that `h-full` resolves to the
|
|
113
113
|
content height, so the padding actually lengthens the scroll. The conclusion panel
|
|
114
114
|
is a separate sibling, so it keeps its own spacing. */}
|
|
@@ -5,11 +5,11 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
|
5
5
|
|
|
6
6
|
import { cn } from "../../utils/cn";
|
|
7
7
|
import { formBarItemStyles } from "../TabFormItem";
|
|
8
|
-
import { FormHeaderBar, type HeaderVariant } from "
|
|
8
|
+
import { FormHeaderBar, type HeaderVariant } from "./header";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Detail-tabs — a display-only view where a left **sidebar** switches the main area between
|
|
12
|
-
* **`
|
|
12
|
+
* **`FormRenderer.Section` panels** (a detail page, not a form). It lives on `FormRenderer`
|
|
13
13
|
* (`FormRenderer.Sidebar` / `.Sidebar.Item` / `.Tab`) so `FormBuilder` stays form-only. Built on
|
|
14
14
|
* the same Radix Tabs primitive shadcn uses: the sidebar is the `Tabs.List`, each `Sidebar.Item` a
|
|
15
15
|
* `Tabs.Trigger`, each `Tab` a `Tabs.Content` — so the fixed rail and the panels share tab state.
|
|
@@ -20,7 +20,7 @@ export interface DetailSidebarProps {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* `FormRenderer.Sidebar` — the tab rail, sitting where a `
|
|
23
|
+
* `FormRenderer.Sidebar` — the tab rail, sitting where a `FormRenderer.Stepper`'s nav would.
|
|
24
24
|
* Renders **nothing itself**: the FormRenderer root detects it and places its `Sidebar.Item`
|
|
25
25
|
* children (the tab triggers) into the fixed rail.
|
|
26
26
|
*/
|
|
@@ -80,7 +80,7 @@ export interface DetailRowProps {
|
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
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 `
|
|
83
|
+
* form field). Drop several inside a `FormRenderer.Grid` within a `FormRenderer.Section`.
|
|
84
84
|
*/
|
|
85
85
|
export function DetailRow({ label, value }: DetailRowProps) {
|
|
86
86
|
return (
|
|
@@ -110,7 +110,7 @@ const GRID_COLS: Record<NonNullable<DetailGridProps["columns"]>, string> = {
|
|
|
110
110
|
/**
|
|
111
111
|
* `FormRenderer.Grid` — arranges `FormRenderer.Row`s in an equal-column grid spanning the **full
|
|
112
112
|
* section width** (default 2 columns, so cells split in half). Padded so the rows breathe inside a
|
|
113
|
-
* `
|
|
113
|
+
* `FormRenderer.Section` (clear of the title badge, roomy row + column spacing).
|
|
114
114
|
*/
|
|
115
115
|
export function DetailGrid({ columns = 2, children }: DetailGridProps) {
|
|
116
116
|
return (
|
|
@@ -121,7 +121,7 @@ export function DetailGrid({ columns = 2, children }: DetailGridProps) {
|
|
|
121
121
|
export interface DetailTabProps {
|
|
122
122
|
/** Ties this panel to the `Sidebar.Item` of the same `value`. */
|
|
123
123
|
value: string;
|
|
124
|
-
/** The panel body — typically `
|
|
124
|
+
/** The panel body — typically `FormRenderer.Section` display blocks. */
|
|
125
125
|
children: React.ReactNode;
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Children, useId } from "react";
|
|
4
|
-
import { FieldValues } from "react-hook-form";
|
|
3
|
+
import { Children, useId, type ReactElement, type ReactNode } from "react";
|
|
4
|
+
import { useForm, type Control, type FieldValues } from "react-hook-form";
|
|
5
5
|
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
6
7
|
import { FormBuilder } from "../FormBuilder";
|
|
7
8
|
import { FormIdContext, LoadingContext } from "../FormBuilder/context";
|
|
8
|
-
import { StepperActions } from "../FormBuilder/stepper";
|
|
9
9
|
import {
|
|
10
10
|
DetailSidebar,
|
|
11
11
|
DetailTab,
|
|
@@ -16,20 +16,39 @@ import {
|
|
|
16
16
|
isDetailTabElement,
|
|
17
17
|
} from "./detail";
|
|
18
18
|
import { FormDrawer } from "./FormDrawer";
|
|
19
|
+
import { FormHeaderBar } from "./header";
|
|
20
|
+
import { Section } from "./section";
|
|
21
|
+
import {
|
|
22
|
+
Back,
|
|
23
|
+
Next,
|
|
24
|
+
Step,
|
|
25
|
+
StepSlot,
|
|
26
|
+
Stepper,
|
|
27
|
+
StepperActions,
|
|
28
|
+
StepperContext,
|
|
29
|
+
StepperNav,
|
|
30
|
+
isStepElement,
|
|
31
|
+
isStepperElement,
|
|
32
|
+
useStepperState,
|
|
33
|
+
type StepProps,
|
|
34
|
+
} from "./stepper";
|
|
19
35
|
import type { FormRendererProps } from "./types";
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
|
-
* FormRenderer —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
38
|
+
* FormRenderer — the chrome around a `FormBuilder`.
|
|
39
|
+
*
|
|
40
|
+
* `FormBuilder` is the fields and nothing else: a `<form>`, its react-hook-form context, and the
|
|
41
|
+
* field components. Every pixel drawn *around* those fields is here — page-vs-drawer display, the
|
|
42
|
+
* absolute title header, the `actions` bar, titled `FormRenderer.Section` cards, the page gutters,
|
|
43
|
+
* the scroll shell, the wizard rail (`FormRenderer.Stepper`), and the `summary` column.
|
|
25
44
|
*
|
|
26
45
|
* FormRenderer never manufactures a Submit — you compose it and hand it to `actions`
|
|
27
|
-
* (`actions={<FormBuilder.Submit>Save</FormBuilder.Submit>}`). It renders in the form's
|
|
28
|
-
*
|
|
46
|
+
* (`actions={<FormBuilder.Submit>Save</FormBuilder.Submit>}`). It renders in the form's header
|
|
47
|
+
* action pill (page) or the drawer header (drawer), and auto-targets this form.
|
|
29
48
|
*
|
|
30
49
|
* 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
|
|
32
|
-
* panels — the sidebar sits where a stepper's rail would.
|
|
50
|
+
* a **detail-tabs** view: a display-only page (no `<form>`) whose sidebar swaps
|
|
51
|
+
* `FormRenderer.Section` panels — the sidebar sits where a stepper's rail would.
|
|
33
52
|
*/
|
|
34
53
|
function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
35
54
|
children,
|
|
@@ -65,12 +84,35 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
65
84
|
const autoId = useId();
|
|
66
85
|
const formId = id ?? autoId;
|
|
67
86
|
|
|
87
|
+
// The form instance is created HERE, not in FormBuilder, because the stepper needs `trigger` to
|
|
88
|
+
// validate a step before advancing — and the rail that calls it renders outside the `<form>`.
|
|
89
|
+
// Same pattern FormBuilder uses: one is always created (hooks can't be conditional), the
|
|
90
|
+
// caller's hoisted instance wins when given.
|
|
91
|
+
const ownForm = useForm<T>({ resolver, defaultValues, values });
|
|
92
|
+
const formInstance = form ?? ownForm;
|
|
93
|
+
|
|
94
|
+
const childArray = Children.toArray(children);
|
|
95
|
+
|
|
68
96
|
// Detail-tabs mode: a `FormRenderer.Sidebar` + `FormRenderer.Tab` children mean a display-only
|
|
69
97
|
// detail page (no `<form>`) — the sidebar swaps Section panels via Radix Tabs. Detected here so
|
|
70
98
|
// the form props below are simply unused.
|
|
71
|
-
const childArray = Children.toArray(children);
|
|
72
99
|
const detailSidebar = childArray.find(isDetailSidebarElement);
|
|
73
100
|
const detailTabs = childArray.filter(isDetailTabElement);
|
|
101
|
+
|
|
102
|
+
// Split out a `FormRenderer.Stepper`: its Steps become the visibility-toggled slots inside the
|
|
103
|
+
// `<form>`, its non-Step children a footer, and its state drives the rail beside them.
|
|
104
|
+
const stepperEl = childArray.find(isStepperElement);
|
|
105
|
+
const stepChildren = stepperEl ? Children.toArray(stepperEl.props.children) : [];
|
|
106
|
+
const steps = stepChildren.filter(isStepElement) as ReactElement<StepProps>[];
|
|
107
|
+
const stepExtras = stepChildren.filter((n) => !isStepElement(n));
|
|
108
|
+
const isStepper = !!stepperEl;
|
|
109
|
+
|
|
110
|
+
// Called unconditionally (inert when there are no steps) to keep hooks order stable.
|
|
111
|
+
const stepper = useStepperState(
|
|
112
|
+
steps,
|
|
113
|
+
formInstance.trigger as Parameters<typeof useStepperState>[1],
|
|
114
|
+
);
|
|
115
|
+
|
|
74
116
|
if (detailSidebar && detailTabs.length > 0) {
|
|
75
117
|
return (
|
|
76
118
|
<DetailTabsView
|
|
@@ -83,35 +125,118 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
83
125
|
);
|
|
84
126
|
}
|
|
85
127
|
|
|
86
|
-
|
|
128
|
+
// The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras), or the
|
|
129
|
+
// children as authored. Every step stays mounted so the whole form is registered at all times.
|
|
130
|
+
const fields: ReactNode = isStepper ? (
|
|
131
|
+
<>
|
|
132
|
+
{steps.map((step, i) => (
|
|
133
|
+
<StepSlot key={i} index={i} active={i === stepper.currentStep}>
|
|
134
|
+
{step.props.children}
|
|
135
|
+
</StepSlot>
|
|
136
|
+
))}
|
|
137
|
+
{stepExtras}
|
|
138
|
+
</>
|
|
139
|
+
) : (
|
|
140
|
+
children
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const formEl = (
|
|
87
144
|
<FormBuilder
|
|
88
145
|
id={formId}
|
|
89
|
-
form={
|
|
146
|
+
form={formInstance}
|
|
90
147
|
onSubmit={onSubmit ?? (() => {})}
|
|
91
148
|
onInvalid={onInvalid}
|
|
92
|
-
resolver={resolver}
|
|
93
|
-
defaultValues={defaultValues}
|
|
94
|
-
values={values}
|
|
95
149
|
loading={loading}
|
|
96
150
|
fieldDirection={effectiveDirection}
|
|
97
151
|
resetOnSuccess={resetOnSuccess}
|
|
98
|
-
// Page display only — in a drawer the panel owns the sizing.
|
|
99
|
-
className={!isDrawer ? className : undefined}
|
|
100
|
-
// Page display: FormBuilder lays the conclusion as the grid's right column (a stepper adds
|
|
101
|
-
// the left nav column too). In a drawer the summary goes to FormDrawer's tray instead.
|
|
102
|
-
conclusion={!isDrawer ? summary : undefined}
|
|
103
152
|
>
|
|
104
|
-
{
|
|
105
|
-
<FormBuilder.Header title={header!.title} label={header!.label} variant={header!.variant}>
|
|
106
|
-
{/* A stepper form prepends chevron Back/Next + a divider before the Submit. */}
|
|
107
|
-
{actions && <StepperActions>{actions}</StepperActions>}
|
|
108
|
-
</FormBuilder.Header>
|
|
109
|
-
)}
|
|
110
|
-
|
|
111
|
-
{children}
|
|
153
|
+
{fields}
|
|
112
154
|
</FormBuilder>
|
|
113
155
|
);
|
|
114
156
|
|
|
157
|
+
// The fields column caps at 1100px and centres — standalone, and as the middle column of the
|
|
158
|
+
// stepper grid. This is page framing, which is why it wraps FormBuilder rather than living
|
|
159
|
+
// inside it: a form embedded in a 260px rail has no room to give up 96px to gutters.
|
|
160
|
+
const fieldsColumn = <div className="mx-auto w-full max-w-[1100px] px-[48px]">{formEl}</div>;
|
|
161
|
+
|
|
162
|
+
// Inside the form surface: the stepper rail beside the fields. Columns never wrap — the layout
|
|
163
|
+
// stays side-by-side at every screen size; when space runs short the fields column shrinks and
|
|
164
|
+
// the rail's labels truncate.
|
|
165
|
+
//
|
|
166
|
+
// `minmax(180px,1fr)` and not a bare `1fr` on the side tracks. `1fr` means `minmax(auto,1fr)`,
|
|
167
|
+
// whose *auto* minimum stops a track shrinking below its content — so a long step label made the
|
|
168
|
+
// rail's track wider than the empty track opposite it, and the two side tracks being equal is the
|
|
169
|
+
// only thing centring the middle column. Hovering a step made it visibly worse: the label's
|
|
170
|
+
// padding grows 6px→9px on hover (FormStepper), so the centre jumped 3px every time.
|
|
171
|
+
//
|
|
172
|
+
// The minimum is a fixed 180px rather than `auto` precisely so it cannot depend on the labels:
|
|
173
|
+
// both side tracks size identically whatever the rail holds, so the middle column stays centred,
|
|
174
|
+
// and the rail still has room to be legible (a plain `minmax(0,…)` let it collapse to 0 in a
|
|
175
|
+
// narrow preview frame). Past that floor the labels truncate instead of pushing.
|
|
176
|
+
const bodyInner = isStepper ? (
|
|
177
|
+
<div className="grid w-full grid-cols-[minmax(180px,1fr)_minmax(0,1100px)_minmax(180px,1fr)] gap-8">
|
|
178
|
+
<StepperNav control={formInstance.control as Control<FieldValues>} />
|
|
179
|
+
{fieldsColumn}
|
|
180
|
+
{/* Empty third column — balances the rail's gutter so the middle column is centred. */}
|
|
181
|
+
<div />
|
|
182
|
+
</div>
|
|
183
|
+
) : (
|
|
184
|
+
fieldsColumn
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const surface = useHeader ? (
|
|
188
|
+
// Scroll shell: the absolute header floats over the scrollable body. No fixed height — it
|
|
189
|
+
// fills whatever the parent gives it (like the drawer's panel fills its tray) and the body
|
|
190
|
+
// scrolls internally.
|
|
191
|
+
<div className="relative isolate flex h-full w-full flex-col overflow-hidden rounded-2xl bg-background-presentation-body-primary">
|
|
192
|
+
<FormHeaderBar title={header!.title} label={header!.label} variant={header!.variant}>
|
|
193
|
+
{/* A stepper form prepends chevron Back/Next + a divider before the Submit. */}
|
|
194
|
+
{actions && <StepperActions>{actions}</StepperActions>}
|
|
195
|
+
</FormHeaderBar>
|
|
196
|
+
<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">
|
|
197
|
+
{bodyInner}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
) : (
|
|
201
|
+
bodyInner
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// The summary (right) lives OUTSIDE the scroll surface — its own panel beside it (mirroring the
|
|
205
|
+
// drawer's tray, a 6px gutter). Only the surface's body scrolls; the summary stays put. In a
|
|
206
|
+
// drawer it goes to FormDrawer's tray instead.
|
|
207
|
+
const pageBody =
|
|
208
|
+
summary && !isDrawer ? (
|
|
209
|
+
<div className="flex h-full flex-row items-stretch">
|
|
210
|
+
<div className="min-h-0 min-w-0 flex-1">{surface}</div>
|
|
211
|
+
<div className="ml-[6px] flex min-h-0">{summary}</div>
|
|
212
|
+
</div>
|
|
213
|
+
) : (
|
|
214
|
+
surface
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// `className` lands on the OUTERMOST element — the one a parent lays out (e.g. `flex-1 min-h-0`
|
|
218
|
+
// to fill a flex column). `h-full` fills a parent that has a definite height.
|
|
219
|
+
//
|
|
220
|
+
// The form-id and loading contexts are supplied HERE as well as inside FormBuilder, because the
|
|
221
|
+
// header's action pill renders outside the `<FormBuilder>` subtree: a bare `FormBuilder.Submit`
|
|
222
|
+
// in `actions` reads the id from context to target the `<form>` by native form-association.
|
|
223
|
+
// Same reason the drawer branch below re-provides them around its own header.
|
|
224
|
+
const inner = (
|
|
225
|
+
<LoadingContext.Provider value={!!loading}>
|
|
226
|
+
<FormIdContext.Provider value={formId}>
|
|
227
|
+
<div className={cn("h-full w-full", !isDrawer && className)}>{pageBody}</div>
|
|
228
|
+
</FormIdContext.Provider>
|
|
229
|
+
</LoadingContext.Provider>
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// One provider around the whole tree: the rail renders outside the `<form>`, the step slots
|
|
233
|
+
// inside it, and both read the same state.
|
|
234
|
+
const tree = isStepper ? (
|
|
235
|
+
<StepperContext.Provider value={stepper}>{inner}</StepperContext.Provider>
|
|
236
|
+
) : (
|
|
237
|
+
inner
|
|
238
|
+
);
|
|
239
|
+
|
|
115
240
|
if (isDrawer) {
|
|
116
241
|
return (
|
|
117
242
|
<FormDrawer
|
|
@@ -132,21 +257,28 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
132
257
|
}
|
|
133
258
|
onOpenInNewTab={onOpenInNewTab}
|
|
134
259
|
>
|
|
135
|
-
{
|
|
260
|
+
{tree}
|
|
136
261
|
</FormDrawer>
|
|
137
262
|
);
|
|
138
263
|
}
|
|
139
264
|
|
|
140
|
-
|
|
141
|
-
return inner;
|
|
265
|
+
return tree;
|
|
142
266
|
}
|
|
143
267
|
|
|
144
268
|
/**
|
|
145
|
-
* FormRenderer — see {@link FormRendererRoot}. The compound statics
|
|
146
|
-
*
|
|
147
|
-
* `FormRenderer.
|
|
269
|
+
* FormRenderer — see {@link FormRendererRoot}. The compound statics are the form's chrome:
|
|
270
|
+
*
|
|
271
|
+
* - `FormRenderer.Section` — a titled card grouping fields (or display rows).
|
|
272
|
+
* - `FormRenderer.Stepper` / `.Step` — the wizard; `.Back` / `.Next` are the chevron controls,
|
|
273
|
+
* which the header's action bar already prepends for you.
|
|
274
|
+
* - `FormRenderer.Sidebar` / `.Tab` / `.Grid` / `.Row` — the display-only **detail-tabs** view.
|
|
148
275
|
*/
|
|
149
276
|
export const FormRenderer = Object.assign(FormRendererRoot, {
|
|
277
|
+
Section,
|
|
278
|
+
Stepper,
|
|
279
|
+
Step,
|
|
280
|
+
Back,
|
|
281
|
+
Next,
|
|
150
282
|
Sidebar: DetailSidebar,
|
|
151
283
|
Tab: DetailTab,
|
|
152
284
|
Grid: DetailGrid,
|
|
@@ -29,8 +29,10 @@ export interface FormHeaderBarProps {
|
|
|
29
29
|
* The floating form header: a `HeaderBar` title pill on the left and a dark action
|
|
30
30
|
* pill on the right, absolutely positioned over the scrollable body.
|
|
31
31
|
*
|
|
32
|
-
* Shared by
|
|
33
|
-
*
|
|
32
|
+
* Shared by all three FormRenderer surfaces — the page form, `FormDrawer` and the
|
|
33
|
+
* detail view — so a form's title looks identical wherever it is rendered. This is
|
|
34
|
+
* chrome, so it lives here rather than in `FormBuilder`: a bare `<FormBuilder>` is
|
|
35
|
+
* fields and nothing else.
|
|
34
36
|
*
|
|
35
37
|
* Per the design both pills are 44px tall, reached differently: the title pill is
|
|
36
38
|
* 6px padding + 32px content, the action pill 8px + 28px.
|
|
@@ -70,32 +72,17 @@ export function FormHeaderBar({
|
|
|
70
72
|
);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The `header` prop's shape — `header={{ title, label, variant }}` on `FormRenderer`.
|
|
77
|
+
* There is no `FormRenderer.Header` component: the header is a prop, not a child, so a
|
|
78
|
+
* child element can never reconfigure the surrounding layout the way the old
|
|
79
|
+
* `FormBuilder.Header` did.
|
|
80
|
+
*/
|
|
81
|
+
export interface HeaderConfig {
|
|
74
82
|
/** Plain title text (uppercased), e.g. the entity label or SKU. */
|
|
75
83
|
title: string;
|
|
76
84
|
/** Badge text — defaults from `variant` (New / Edit / View). */
|
|
77
85
|
label?: string;
|
|
78
|
-
/** Colored badge variant.
|
|
86
|
+
/** Colored badge variant. */
|
|
79
87
|
variant?: HeaderVariant;
|
|
80
|
-
/** Action buttons shown in the right-hand action pill (e.g. FormBuilder.Submit). */
|
|
81
|
-
children?: ReactNode;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* `FormBuilder.Header` — the page form's title + action bar. A thin, mode-aware
|
|
86
|
-
* wrapper over `FormHeaderBar`: it defaults the badge variant from the form `mode`
|
|
87
|
-
* (view → detail).
|
|
88
|
-
*
|
|
89
|
-
* Place it as a direct child of `<FormBuilder>`; the root then switches to the
|
|
90
|
-
* scroll-shell layout that reserves space beneath the floating header.
|
|
91
|
-
*/
|
|
92
|
-
export function Header({ title, label, variant = "new", children }: HeaderProps) {
|
|
93
|
-
return (
|
|
94
|
-
<FormHeaderBar title={title} label={label} variant={variant}>
|
|
95
|
-
{children}
|
|
96
|
-
</FormHeaderBar>
|
|
97
|
-
);
|
|
98
88
|
}
|
|
99
|
-
|
|
100
|
-
// Marker so the FormBuilder root can detect the header among its children.
|
|
101
|
-
(Header as unknown as { __isFormHeader: boolean }).__isFormHeader = true;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { FormRenderer } from "./form-renderer";
|
|
2
2
|
export { FormDrawer } from "./FormDrawer";
|
|
3
3
|
export type { FormDrawerProps } from "./FormDrawer";
|
|
4
|
+
export { FormHeaderBar } from "./header";
|
|
5
|
+
export type { HeaderConfig, HeaderVariant, FormHeaderBarProps } from "./header";
|
|
6
|
+
export type { SectionProps } from "./section";
|
|
7
|
+
export type { StepProps, StepperProps } from "./stepper";
|
|
4
8
|
export type { FormRendererProps, FormRendererDisplay, FieldDirection } from "./types";
|
|
5
9
|
export type {
|
|
6
10
|
DetailSidebarProps,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { SectionBlock, type SectionColor, type SectionVariant } from "../SectionBlock";
|
|
6
|
+
|
|
7
|
+
export interface SectionProps {
|
|
8
|
+
title?: ReactNode;
|
|
9
|
+
color?: SectionColor;
|
|
10
|
+
icon?: ReactNode;
|
|
11
|
+
/**
|
|
12
|
+
* `"Table"` switches to the full-bleed table shell — no body padding, a rule under
|
|
13
|
+
* the header, and the card clipped to its radius. `FormBuilder.Table` uses it; pass
|
|
14
|
+
* it here only when hand-composing a table inside a section.
|
|
15
|
+
*/
|
|
16
|
+
variant?: SectionVariant;
|
|
17
|
+
/** Right-aligned content on the title row — e.g. action buttons. */
|
|
18
|
+
action?: ReactNode;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `FormRenderer.Section` — a titled Glare `SectionBlock` grouping fields.
|
|
24
|
+
*
|
|
25
|
+
* It lives here, not on `FormBuilder`, because it is presentation: a coloured card with a title
|
|
26
|
+
* row and an action slot. It holds no form state and reads no form context, so it works just as
|
|
27
|
+
* well around the read-only `FormRenderer.Grid` rows of a detail page as it does around fields.
|
|
28
|
+
*
|
|
29
|
+
* The per-field row layout — label, required marker, hint, the two-column split — is a different
|
|
30
|
+
* thing entirely: that is `FieldSection`, applied by every field itself, and it stays inside
|
|
31
|
+
* FormBuilder where the fields are.
|
|
32
|
+
*/
|
|
33
|
+
export function Section({ title, color, icon, variant, action, children }: SectionProps) {
|
|
34
|
+
return (
|
|
35
|
+
<SectionBlock title={title} color={color} icon={icon} variant={variant} action={action}>
|
|
36
|
+
{children}
|
|
37
|
+
</SectionBlock>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { useFormState, type FieldPath, type FieldValues } from "react-hook-form";
|
|
4
|
+
import { useFormState, type Control, type FieldPath, type FieldValues } from "react-hook-form";
|
|
5
5
|
|
|
6
6
|
import { cn } from "../../utils/cn";
|
|
7
7
|
import { Button } from "../Button";
|
|
8
|
+
// The step *registry* stays with the fields: `FieldShell` registers each field name into it, so
|
|
9
|
+
// it must ship with FormBuilder (which is installable on its own). Everything else about the
|
|
10
|
+
// stepper — the rail, the state, Back/Next — is chrome and lives here. The dependency only ever
|
|
11
|
+
// points this way: FormRenderer → FormBuilder, never back.
|
|
12
|
+
import { StepContext, type StepRegistry } from "../FormBuilder/context";
|
|
8
13
|
import { FormStepper, FormStep, FormStepIndicator, FormStepLabel } from "../FormStepper";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
|
|
15
|
+
// ─── Stepper state context ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Stepper state shared by the nav, the step slots and the Back/Next buttons. */
|
|
18
|
+
export interface StepperContextValue {
|
|
19
|
+
currentStep: number;
|
|
20
|
+
totalSteps: number;
|
|
21
|
+
titles: string[];
|
|
22
|
+
isFirstStep: boolean;
|
|
23
|
+
isLastStep: boolean;
|
|
24
|
+
goToNext: () => void | Promise<void>;
|
|
25
|
+
goToPrevious: () => void;
|
|
26
|
+
goToStep: (index: number) => void;
|
|
27
|
+
/** Field names registered per step, for per-step validation. */
|
|
28
|
+
stepFields: Record<number, Set<string>>;
|
|
29
|
+
/** Steps that have passed validation — stay checked even after navigating back. */
|
|
30
|
+
completedSteps: Set<number>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const StepperContext = React.createContext<StepperContextValue | null>(null);
|
|
34
|
+
|
|
35
|
+
export const useStepper = () => {
|
|
36
|
+
const ctx = React.useContext(StepperContext);
|
|
37
|
+
if (!ctx) throw new Error("FormRenderer.Step/Back/Next must be used within FormRenderer.Stepper");
|
|
38
|
+
return ctx;
|
|
39
|
+
};
|
|
16
40
|
|
|
17
41
|
// ─── Step (declaration only — the Stepper reads its props) ───────────────────
|
|
18
42
|
|
|
@@ -23,7 +47,7 @@ export interface StepProps {
|
|
|
23
47
|
}
|
|
24
48
|
|
|
25
49
|
/**
|
|
26
|
-
* Declares a wizard step. It renders nothing itself — `
|
|
50
|
+
* Declares a wizard step. It renders nothing itself — `FormRenderer.Stepper`
|
|
27
51
|
* collects Step elements as an array and renders every step's fields (so the
|
|
28
52
|
* **whole form is registered**), toggling visibility per the active step.
|
|
29
53
|
*/
|
|
@@ -76,16 +100,23 @@ function StepSlot({
|
|
|
76
100
|
/**
|
|
77
101
|
* Vertical step rail (matches `Tabs.svg`): the numbered state badges stacked top
|
|
78
102
|
* to bottom, joined by a short vertical connector between consecutive steps.
|
|
103
|
+
*
|
|
104
|
+
* `control` is passed in rather than read from context: the rail renders in its own grid column
|
|
105
|
+
* **outside** the `<form>`, so there is no `FormProvider` above it to read. Same reason
|
|
106
|
+
* `useStepperState` takes `trigger` as an argument.
|
|
79
107
|
*/
|
|
80
|
-
function StepperNav() {
|
|
108
|
+
function StepperNav({ control }: { control: Control<FieldValues> }) {
|
|
81
109
|
const { titles, currentStep, goToStep, stepFields, completedSteps } = useStepper();
|
|
82
|
-
const { errors } = useFormState();
|
|
110
|
+
const { errors } = useFormState({ control });
|
|
83
111
|
|
|
84
112
|
const stepHasError = (index: number) =>
|
|
85
113
|
[...(stepFields[index] ?? [])].some((name) => name in errors);
|
|
86
114
|
|
|
87
115
|
return (
|
|
88
|
-
|
|
116
|
+
// `min-w-0` so the rail can be squeezed: its grid track no longer grows to fit a long label
|
|
117
|
+
// (see form-renderer.tsx), so the column has to be allowed to shrink and let the labels
|
|
118
|
+
// truncate instead of spilling over the fields column.
|
|
119
|
+
<FormStepper activeStep={currentStep} className="min-w-0 shrink-0 flex-col items-start gap-[4px]">
|
|
89
120
|
{titles.map((title, index) => {
|
|
90
121
|
// The step buttons ARE the navigation: click to move. Backward is free;
|
|
91
122
|
// clicking forward validates the steps in between (goToStep) and stops at
|
|
@@ -98,9 +129,21 @@ function StepperNav() {
|
|
|
98
129
|
: "default";
|
|
99
130
|
return (
|
|
100
131
|
<React.Fragment key={title}>
|
|
101
|
-
|
|
132
|
+
{/* The shrink/truncate pair is passed in rather than changed in `FormStepper`, so its
|
|
133
|
+
other consumers keep sizing to their labels. `shrink` beats the pill's own
|
|
134
|
+
`shrink-0` and `truncate` beats the label's `whitespace-nowrap` — both via
|
|
135
|
+
tailwind-merge, which `FormStepper` runs the caller's className through. */}
|
|
136
|
+
<FormStep
|
|
137
|
+
index={index}
|
|
138
|
+
type={type}
|
|
139
|
+
onClick={() => void goToStep(index)}
|
|
140
|
+
// `max-w-full` is what actually makes the label truncate: the rail is
|
|
141
|
+
// `items-start`, so without a cap the pill shrink-wraps its label and grows straight
|
|
142
|
+
// past the track instead of clipping inside it.
|
|
143
|
+
className="min-w-0 max-w-full shrink"
|
|
144
|
+
>
|
|
102
145
|
<FormStepIndicator />
|
|
103
|
-
<FormStepLabel>{title}</FormStepLabel>
|
|
146
|
+
<FormStepLabel className="truncate">{title}</FormStepLabel>
|
|
104
147
|
</FormStep>
|
|
105
148
|
{/* Connector between steps — a 3×16 rounded bar centred under the badge. */}
|
|
106
149
|
{index < titles.length - 1 && (
|
|
@@ -150,13 +193,13 @@ function StepNavButton({
|
|
|
150
193
|
);
|
|
151
194
|
}
|
|
152
195
|
|
|
153
|
-
/** `
|
|
196
|
+
/** `FormRenderer.Back` — chevron to the previous step; disabled on the first. */
|
|
154
197
|
export function Back() {
|
|
155
198
|
const { goToPrevious, isFirstStep } = useStepper();
|
|
156
199
|
return <StepNavButton dir="left" onClick={goToPrevious} disabled={isFirstStep} />;
|
|
157
200
|
}
|
|
158
201
|
|
|
159
|
-
/** `
|
|
202
|
+
/** `FormRenderer.Next` — chevron to the next step (validates first); disabled on the last. */
|
|
160
203
|
export function Next() {
|
|
161
204
|
const { goToNext, isLastStep } = useStepper();
|
|
162
205
|
return <StepNavButton dir="right" onClick={() => void goToNext()} disabled={isLastStep} />;
|
|
@@ -165,7 +208,7 @@ export function Next() {
|
|
|
165
208
|
// ─── Stepper action bar (Back/Next + divider, then the Submit) ────────────────
|
|
166
209
|
|
|
167
210
|
/**
|
|
168
|
-
*
|
|
211
|
+
* The FormRenderer root wraps its `actions` in this. When the form is a stepper it prepends the
|
|
169
212
|
* chevron `Back`/`Next` controls + a divider before the (user-provided) Submit — the Figma
|
|
170
213
|
* `Body-HeaderBar-1.0` layout. Outside a stepper there's no `StepperContext`, so it renders
|
|
171
214
|
* the actions untouched.
|
|
@@ -191,10 +234,10 @@ export interface StepperProps {
|
|
|
191
234
|
}
|
|
192
235
|
|
|
193
236
|
/**
|
|
194
|
-
* `
|
|
195
|
-
*
|
|
237
|
+
* `FormRenderer.Stepper` — declares a wizard. It renders **nothing itself**: the
|
|
238
|
+
* FormRenderer root detects it, lifts its state (so the nav can live in its own
|
|
196
239
|
* grid column *outside* the `<form>`), and renders `[nav | active step's fields]`.
|
|
197
|
-
* Children are `
|
|
240
|
+
* Children are `FormRenderer.Step`s (plus an optional custom footer).
|
|
198
241
|
*
|
|
199
242
|
* Every step's fields stay registered with react-hook-form at all times — the
|
|
200
243
|
* stepper only controls which step is *visible*. **Navigation is the step buttons
|
|
@@ -202,7 +245,7 @@ export interface StepperProps {
|
|
|
202
245
|
* validates every step in between and stops at the first with errors (which shows a
|
|
203
246
|
* red indicator). Only the last step shows the Submit button.
|
|
204
247
|
*/
|
|
205
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- children are read by the
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- children are read by the FormRenderer root
|
|
206
249
|
export function Stepper(_props: StepperProps) {
|
|
207
250
|
return null;
|
|
208
251
|
}
|
|
@@ -218,7 +261,7 @@ export function isStepperElement(node: React.ReactNode): node is React.ReactElem
|
|
|
218
261
|
type TriggerFn = (names?: FieldPath<FieldValues>[]) => Promise<boolean>;
|
|
219
262
|
|
|
220
263
|
/**
|
|
221
|
-
* Stepper state — lifted to the
|
|
264
|
+
* Stepper state — lifted to the FormRenderer root so the nav (rail) and the step
|
|
222
265
|
* fields can render in separate grid columns while sharing one state. `trigger` is
|
|
223
266
|
* the form's `trigger` (passed in — no `useFormContext` needed). Inert when
|
|
224
267
|
* `steps` is empty (a form without a stepper still calls this, for hooks order).
|
|
@@ -8,18 +8,18 @@ import type {
|
|
|
8
8
|
} from "react-hook-form";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* FormRenderer —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* FormRenderer — the chrome around a `FormBuilder`. You author the fields as **JSX children**
|
|
12
|
+
* (`FormRenderer.Section`, `FormBuilder.Text`, …); the renderer owns everything drawn around
|
|
13
|
+
* them: page-vs-drawer display, the absolute title header, the `actions` bar, the titled section
|
|
14
|
+
* cards, the page gutters and scroll shell, the wizard rail, the `summary` column, and vertical
|
|
15
|
+
* field layout inside a drawer.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
export type FormRendererDisplay = "page" | "drawer";
|
|
19
|
-
export type FieldDirection = "horizontal" | "vertical";
|
|
19
|
+
export type FieldDirection = "horizontal" | "vertical" | "flexible";
|
|
20
20
|
|
|
21
21
|
export interface FormRendererProps<T extends FieldValues = FieldValues> {
|
|
22
|
-
/** The form body — `
|
|
22
|
+
/** The form body — `FormRenderer.Section` / field / `FormRenderer.Stepper` JSX. */
|
|
23
23
|
children: ReactNode;
|
|
24
24
|
|
|
25
25
|
// --- react-hook-form root (forwarded to FormBuilder) ---
|
|
@@ -14,7 +14,17 @@ const titleBadge = cva(
|
|
|
14
14
|
Orange: "bg-red-orange-900",
|
|
15
15
|
Purple: "bg-violet-900",
|
|
16
16
|
Pink: "bg-medium-violet-red-900",
|
|
17
|
-
Gray
|
|
17
|
+
// Per Figma (`FormHeader10`, Color=Gray), which pairs two *variables*: the pill is
|
|
18
|
+
// `background/presentation/button/primary` and its label `content/presentation/global/
|
|
19
|
+
// primary-inverse`. They invert together — a light pill with dark text in the dark theme,
|
|
20
|
+
// dark pill with white text in the light one — so the text token has to ride along on the
|
|
21
|
+
// variant. It overrides the base `text-[#F4F4F4]` because `titleBadge` goes through `cn`
|
|
22
|
+
// (tailwind-merge) below; the other seven are dark palette chips that still want that base.
|
|
23
|
+
//
|
|
24
|
+
// This previously read `bg-background-presentation-badge-gray`, which is not a token at all:
|
|
25
|
+
// every badge colour has `-solid`/`-subtle` and none has a bare name, so the utility was
|
|
26
|
+
// never generated and a Gray badge painted no background whatsoever.
|
|
27
|
+
Gray: "bg-background-presentation-button-primary text-content-presentation-global-primary-inverse",
|
|
18
28
|
},
|
|
19
29
|
},
|
|
20
30
|
defaultVariants: { color: "Blue" },
|