torch-glare 2.4.3 → 2.4.4
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/form-builder.tsx +7 -8
- 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/registry.json +1 -1
- package/apps/lib/tsconfig.tsbuildinfo +1 -1
- package/docs/components/form-builder.md +3 -0
- package/docs/components/form-renderer.md +56 -1
- package/docs/how-to/forms-with-form-builder.md +54 -0
- package/package.json +1 -1
|
@@ -122,11 +122,11 @@ function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
|
122
122
|
// `<form>`. Called unconditionally (inert when there are no steps) to keep hooks order stable.
|
|
123
123
|
const stepper = useStepperState(steps, form.trigger as Parameters<typeof useStepperState>[1]);
|
|
124
124
|
|
|
125
|
-
// The stepper nav is its own column beside the fields, inside the
|
|
125
|
+
// The stepper nav is its own grid column beside the fields, inside the scrolling body.
|
|
126
126
|
const nav = isStepper ? <StepperNav /> : null;
|
|
127
127
|
|
|
128
|
-
// The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras like
|
|
129
|
-
//
|
|
128
|
+
// The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras like Back/Next),
|
|
129
|
+
// or the plain children. The Submit itself lives outside the form (see FormRenderer).
|
|
130
130
|
const fields = isStepper ? (
|
|
131
131
|
<>
|
|
132
132
|
{steps.map((step, i) => (
|
|
@@ -179,13 +179,12 @@ function FormBuilderRoot<T extends FieldValues = FieldValues>({
|
|
|
179
179
|
bodyInner
|
|
180
180
|
);
|
|
181
181
|
|
|
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.
|
|
182
|
+
// The conclusion (right) lives OUTSIDE the scroll surface — its own panel beside it (mirroring the
|
|
183
|
+
// drawer's tray, a 6px gutter). Only the surface's body scrolls; the conclusion stays put.
|
|
185
184
|
const body = conclusion ? (
|
|
186
|
-
<div className="flex h-full flex-row items-stretch
|
|
185
|
+
<div className="flex h-full flex-row items-stretch">
|
|
187
186
|
<div className="min-h-0 min-w-0 flex-1">{surface}</div>
|
|
188
|
-
<div className="flex min-h-0">{conclusion}</div>
|
|
187
|
+
<div className="ml-[6px] flex min-h-0">{conclusion}</div>
|
|
189
188
|
</div>
|
|
190
189
|
) : (
|
|
191
190
|
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>;
|
package/apps/lib/registry.json
CHANGED