torch-glare 2.4.1 → 2.4.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/Button.tsx +1 -1
- package/apps/lib/components/Card.tsx +47 -20
- package/apps/lib/components/ColorPicker.tsx +441 -0
- package/apps/lib/components/ConclusionHeader.tsx +148 -0
- package/apps/lib/components/DatePicker.tsx +2 -0
- package/apps/lib/components/Drawer.tsx +66 -24
- package/apps/lib/components/FormBuilder/context.ts +68 -0
- package/apps/lib/components/FormBuilder/fields/ChoiceFields.tsx +60 -0
- package/apps/lib/components/FormBuilder/fields/ColorField.tsx +59 -0
- package/apps/lib/components/FormBuilder/fields/CustomField.tsx +11 -0
- package/apps/lib/components/FormBuilder/fields/DateField.tsx +32 -0
- package/apps/lib/components/FormBuilder/fields/FieldArray.tsx +74 -0
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +148 -0
- package/apps/lib/components/FormBuilder/fields/FileField.tsx +39 -0
- package/apps/lib/components/FormBuilder/fields/OptionListFields.tsx +132 -0
- package/apps/lib/components/FormBuilder/fields/OtpField.tsx +31 -0
- package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +83 -0
- package/apps/lib/components/FormBuilder/fields/RichTextEditorField.tsx +31 -0
- package/apps/lib/components/FormBuilder/fields/SelectField.tsx +91 -0
- package/apps/lib/components/FormBuilder/fields/SignatureField.tsx +157 -0
- package/apps/lib/components/FormBuilder/fields/SliderField.tsx +67 -0
- package/apps/lib/components/FormBuilder/fields/SwitchBoxField.tsx +41 -0
- package/apps/lib/components/FormBuilder/fields/TableField.tsx +308 -0
- package/apps/lib/components/FormBuilder/fields/TextField.tsx +213 -0
- package/apps/lib/components/FormBuilder/fields/TreeSelectField.tsx +43 -0
- package/apps/lib/components/FormBuilder/fields/countries.ts +303 -0
- package/apps/lib/components/FormBuilder/fields/index.ts +25 -0
- package/apps/lib/components/FormBuilder/form-builder.tsx +279 -0
- package/apps/lib/components/FormBuilder/header.tsx +101 -0
- package/apps/lib/components/FormBuilder/index.ts +33 -0
- package/apps/lib/components/FormBuilder/numberFormat.ts +16 -0
- package/apps/lib/components/FormBuilder/stepper.tsx +290 -0
- package/apps/lib/components/FormBuilder/submit.tsx +38 -0
- package/apps/lib/components/FormBuilder/types.ts +261 -0
- package/apps/lib/components/FormRenderer/FormDrawer.tsx +128 -0
- package/apps/lib/components/FormRenderer/form-renderer.tsx +111 -0
- package/apps/lib/components/FormRenderer/index.ts +4 -0
- package/apps/lib/components/FormRenderer/types.ts +77 -0
- package/apps/lib/components/FormSummary.tsx +282 -0
- package/apps/lib/components/ImageAttachment.tsx +36 -61
- package/apps/lib/components/Label.tsx +49 -42
- package/apps/lib/components/Popover.tsx +6 -2
- package/apps/lib/components/RadioCard.tsx +2 -0
- package/apps/lib/components/SearchableSelect.tsx +16 -5
- package/apps/lib/components/SectionBlock.tsx +16 -8
- package/apps/lib/components/Select.tsx +41 -120
- package/apps/lib/components/TextEditor/RichTextField.tsx +46 -0
- package/apps/lib/components/{TextEditor.tsx → TextEditor/TextEditor.tsx} +63 -9
- package/apps/lib/components/TextEditor/TextEditorToolbar.tsx +429 -0
- package/apps/lib/components/TextEditor/editor-tools/AlignmentTune.ts +70 -0
- package/apps/lib/components/TextEditor/editor-tools/ColorInlineTool.ts +50 -0
- package/apps/lib/components/TextEditor/editor-tools/StrikethroughInlineTool.ts +48 -0
- package/apps/lib/components/TextEditor/editor-tools/inlineFormat.ts +98 -0
- package/apps/lib/{types → components/TextEditor}/editorjs.d.ts +19 -0
- package/apps/lib/components/TextEditor/index.ts +7 -0
- package/apps/lib/components/Textarea.tsx +1 -1
- package/apps/lib/layouts/FieldSection.tsx +25 -22
- package/apps/lib/registry.json +51 -58
- package/apps/lib/tsconfig.tsbuildinfo +1 -0
- package/apps/lib/utils/color.ts +175 -0
- package/dist/src/shared/tailwindInit.d.ts.map +1 -1
- package/dist/src/shared/tailwindInit.js +3 -0
- package/dist/src/shared/tailwindInit.js.map +1 -1
- package/docs/components/card.md +4 -2
- package/docs/components/chart-block-tool.md +5 -4
- package/docs/components/color-picker.md +101 -0
- package/docs/components/conclusion-header.md +80 -0
- package/docs/components/drawer.md +153 -102
- package/docs/components/form-builder.md +260 -0
- package/docs/components/form-renderer.md +234 -0
- package/docs/components/form-summary.md +123 -0
- package/docs/components/image-attachment.md +10 -4
- package/docs/components/searchable-select.md +50 -46
- package/docs/components/table-dnd-wrapper.md +5 -3
- package/docs/components/text-editor.md +18 -0
- package/docs/how-to/form-and-list-recipes.md +26 -19
- package/docs/how-to/forms-with-form-builder.md +411 -0
- package/docs/how-to/guides.md +153 -179
- package/docs/reference/tailwind-plugins.md +11 -1
- package/docs/tutorials/building-first-form.md +150 -159
- package/docs/tutorials/getting-started.md +9 -0
- package/package.json +1 -1
- /package/apps/lib/components/{ChartBlockTool.ts → TextEditor/ChartBlockTool.ts} +0 -0
- /package/apps/lib/components/{TableDnDWrapper.ts → TextEditor/TableDnDWrapper.ts} +0 -0
- /package/apps/lib/{utils → components/TextEditor}/markdownParser.ts +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
import { HeaderBar } from "../HeaderBar";
|
|
7
|
+
|
|
8
|
+
export type HeaderVariant = "new" | "edit" | "detail";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LABEL: Record<HeaderVariant, string> = {
|
|
11
|
+
new: "New",
|
|
12
|
+
edit: "Edit",
|
|
13
|
+
detail: "View",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface FormHeaderBarProps {
|
|
17
|
+
/** Plain title text (uppercased), e.g. the entity label or SKU. */
|
|
18
|
+
title: string;
|
|
19
|
+
/** Badge text — defaults from `variant` (New / Edit / View). */
|
|
20
|
+
label?: string;
|
|
21
|
+
/** Colored badge variant. */
|
|
22
|
+
variant?: HeaderVariant;
|
|
23
|
+
/** Action buttons shown in the right-hand action pill. */
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The floating form header: a `HeaderBar` title pill on the left and a dark action
|
|
30
|
+
* pill on the right, absolutely positioned over the scrollable body.
|
|
31
|
+
*
|
|
32
|
+
* Shared by **both** form surfaces — `FormBuilder.Header` (page) and `FormDrawer`
|
|
33
|
+
* (drawer) — so a form's title looks identical wherever it is rendered.
|
|
34
|
+
*
|
|
35
|
+
* Per the design both pills are 44px tall, reached differently: the title pill is
|
|
36
|
+
* 6px padding + 32px content, the action pill 8px + 28px.
|
|
37
|
+
*/
|
|
38
|
+
export function FormHeaderBar({
|
|
39
|
+
title,
|
|
40
|
+
label,
|
|
41
|
+
variant = "new",
|
|
42
|
+
children,
|
|
43
|
+
className,
|
|
44
|
+
}: FormHeaderBarProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"absolute inset-x-0 top-0 z-[2] flex w-full items-start justify-between gap-2 p-1",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<HeaderBar
|
|
53
|
+
variant={variant}
|
|
54
|
+
label={label ?? DEFAULT_LABEL[variant]}
|
|
55
|
+
title={title}
|
|
56
|
+
theme="dark"
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
{children && (
|
|
60
|
+
<div
|
|
61
|
+
data-theme="dark"
|
|
62
|
+
className="flex flex-col items-start rounded-[14px] border border-border-presentation-global-primary bg-background-presentation-form-base shadow-[0_0_32px_0_rgba(0,0,0,0.15)]"
|
|
63
|
+
>
|
|
64
|
+
<div className="sticky top-0 flex w-full items-center justify-between p-2">
|
|
65
|
+
<div className="flex h-7 items-center justify-center gap-2">{children}</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface HeaderProps {
|
|
74
|
+
/** Plain title text (uppercased), e.g. the entity label or SKU. */
|
|
75
|
+
title: string;
|
|
76
|
+
/** Badge text — defaults from `variant` (New / Edit / View). */
|
|
77
|
+
label?: string;
|
|
78
|
+
/** Colored badge variant. Defaults from the form `mode` (view → detail). */
|
|
79
|
+
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
|
+
}
|
|
99
|
+
|
|
100
|
+
// Marker so the FormBuilder root can detect the header among its children.
|
|
101
|
+
(Header as unknown as { __isFormHeader: boolean }).__isFormHeader = true;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export { FormBuilder } from "./form-builder";
|
|
2
|
+
export type { SectionProps } from "./form-builder";
|
|
3
|
+
export { FormHeaderBar } from "./header";
|
|
4
|
+
export type { HeaderProps, HeaderVariant, FormHeaderBarProps } from "./header";
|
|
5
|
+
export { RichTextField } from "../TextEditor/RichTextField";
|
|
6
|
+
export type { StepProps } from "./stepper";
|
|
7
|
+
export type { FieldDirection } from "./context";
|
|
8
|
+
export type {
|
|
9
|
+
BaseFieldProps,
|
|
10
|
+
OptionItem,
|
|
11
|
+
SelectFieldProps,
|
|
12
|
+
SearchableSelectFieldProps,
|
|
13
|
+
OptionsFieldProps,
|
|
14
|
+
CurrencyFieldProps,
|
|
15
|
+
FileFieldProps,
|
|
16
|
+
CustomFieldProps,
|
|
17
|
+
DateFieldProps,
|
|
18
|
+
OtpFieldProps,
|
|
19
|
+
RadioCardOption,
|
|
20
|
+
RadioCardsFieldProps,
|
|
21
|
+
SwitchBoxFieldProps,
|
|
22
|
+
CheckboxFieldProps,
|
|
23
|
+
TreeSelectFieldProps,
|
|
24
|
+
PasswordFieldProps,
|
|
25
|
+
SliderFieldProps,
|
|
26
|
+
ColorFieldProps,
|
|
27
|
+
SignatureFieldProps,
|
|
28
|
+
PhoneFieldProps,
|
|
29
|
+
FieldArrayProps,
|
|
30
|
+
TableColumn,
|
|
31
|
+
TableFieldProps,
|
|
32
|
+
FormBuilderRootProps,
|
|
33
|
+
} from "./types";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 20 });
|
|
2
|
+
|
|
3
|
+
/** Format a number with thousands separators (e.g. 1299 → "1,299"). Blank for empty. */
|
|
4
|
+
export function formatNumber(value: unknown): string {
|
|
5
|
+
if (value == null || value === "") return "";
|
|
6
|
+
const n = typeof value === "number" ? value : Number(String(value).replace(/,/g, ""));
|
|
7
|
+
return Number.isNaN(n) ? "" : nf.format(n);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Parse a possibly comma-formatted string to a number (or undefined when blank). */
|
|
11
|
+
export function parseNumber(raw: string): number | undefined {
|
|
12
|
+
const cleaned = raw.replace(/,/g, "").trim();
|
|
13
|
+
if (cleaned === "" || cleaned === "-" || cleaned === ".") return undefined;
|
|
14
|
+
const n = Number(cleaned);
|
|
15
|
+
return Number.isNaN(n) ? undefined : n;
|
|
16
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useFormState, type FieldPath, type FieldValues } from "react-hook-form";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
7
|
+
import { Button } from "../Button";
|
|
8
|
+
import { FormStepper, FormStep, FormStepIndicator, FormStepLabel } from "../FormStepper";
|
|
9
|
+
import {
|
|
10
|
+
StepContext,
|
|
11
|
+
StepperContext,
|
|
12
|
+
useStepper,
|
|
13
|
+
type StepperContextValue,
|
|
14
|
+
type StepRegistry,
|
|
15
|
+
} from "./context";
|
|
16
|
+
|
|
17
|
+
// ─── Step (declaration only — the Stepper reads its props) ───────────────────
|
|
18
|
+
|
|
19
|
+
export interface StepProps {
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Declares a wizard step. It renders nothing itself — `FormBuilder.Stepper`
|
|
27
|
+
* collects Step elements as an array and renders every step's fields (so the
|
|
28
|
+
* **whole form is registered**), toggling visibility per the active step.
|
|
29
|
+
*/
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- props are read by Stepper, not rendered here
|
|
31
|
+
export function Step(_props: StepProps) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
(Step as unknown as { __isFormStep: boolean }).__isFormStep = true;
|
|
35
|
+
|
|
36
|
+
export function isStepElement(node: React.ReactNode): node is React.ReactElement<StepProps> {
|
|
37
|
+
return (
|
|
38
|
+
React.isValidElement(node) && (node.type as { __isFormStep?: boolean })?.__isFormStep === true
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── StepSlot — mounts one step's fields; hidden when not active ─────────────
|
|
43
|
+
|
|
44
|
+
function StepSlot({
|
|
45
|
+
index,
|
|
46
|
+
active,
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
index: number;
|
|
50
|
+
active: boolean;
|
|
51
|
+
children: React.ReactNode;
|
|
52
|
+
}) {
|
|
53
|
+
const stepper = useStepper();
|
|
54
|
+
const registry: StepRegistry = React.useMemo(
|
|
55
|
+
() => ({
|
|
56
|
+
register: (name) => {
|
|
57
|
+
(stepper.stepFields[index] ??= new Set()).add(name);
|
|
58
|
+
},
|
|
59
|
+
unregister: (name) => {
|
|
60
|
+
stepper.stepFields[index]?.delete(name);
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
[stepper, index],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Always mounted (fields stay registered); CSS hides inactive steps.
|
|
67
|
+
return (
|
|
68
|
+
<div className={cn("flex flex-col gap-4", !active && "hidden")}>
|
|
69
|
+
<StepContext.Provider value={registry}>{children}</StepContext.Provider>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Stepper navigation (style only) ─────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Vertical step rail (matches `Tabs.svg`): the numbered state badges stacked top
|
|
78
|
+
* to bottom, joined by a short vertical connector between consecutive steps.
|
|
79
|
+
*/
|
|
80
|
+
function StepperNav() {
|
|
81
|
+
const { titles, currentStep, goToStep, stepFields, completedSteps } = useStepper();
|
|
82
|
+
const { errors } = useFormState();
|
|
83
|
+
|
|
84
|
+
const stepHasError = (index: number) =>
|
|
85
|
+
[...(stepFields[index] ?? [])].some((name) => name in errors);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<FormStepper activeStep={currentStep} className="shrink-0 flex-col items-start gap-[4px]">
|
|
89
|
+
{titles.map((title, index) => {
|
|
90
|
+
// The step buttons ARE the navigation: click to move. Backward is free;
|
|
91
|
+
// clicking forward validates the steps in between (goToStep) and stops at
|
|
92
|
+
// the first one with errors. A live error shows red; a step that has passed
|
|
93
|
+
// validation stays checked (success) even after navigating back to it.
|
|
94
|
+
const type = stepHasError(index)
|
|
95
|
+
? "negative"
|
|
96
|
+
: completedSteps.has(index)
|
|
97
|
+
? "success"
|
|
98
|
+
: "default";
|
|
99
|
+
return (
|
|
100
|
+
<React.Fragment key={title}>
|
|
101
|
+
<FormStep index={index} type={type} onClick={() => void goToStep(index)}>
|
|
102
|
+
<FormStepIndicator />
|
|
103
|
+
<FormStepLabel>{title}</FormStepLabel>
|
|
104
|
+
</FormStep>
|
|
105
|
+
{/* Connector between steps — a 3×16 rounded bar centred under the badge. */}
|
|
106
|
+
{index < titles.length - 1 && (
|
|
107
|
+
<div
|
|
108
|
+
aria-hidden
|
|
109
|
+
className="ms-[12.5px] mt-[2px] h-[16px] w-[3px] rounded-full bg-[#A0A0A0]"
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</React.Fragment>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</FormStepper>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Back / Next chevron nav (the Figma header action bar) ───────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Shared chevron nav button, matching the Figma header action bar (Body-HeaderBar-1.0) — the
|
|
123
|
+
* Glare `Button` icon variant, the same control Select/SearchableSelect use for their chevrons.
|
|
124
|
+
*/
|
|
125
|
+
function StepNavButton({
|
|
126
|
+
dir,
|
|
127
|
+
onClick,
|
|
128
|
+
disabled,
|
|
129
|
+
}: {
|
|
130
|
+
dir: "left" | "right";
|
|
131
|
+
onClick: () => void;
|
|
132
|
+
disabled: boolean;
|
|
133
|
+
}) {
|
|
134
|
+
return (
|
|
135
|
+
<Button
|
|
136
|
+
type="button"
|
|
137
|
+
buttonType="icon"
|
|
138
|
+
size="M"
|
|
139
|
+
onClick={onClick}
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
aria-label={dir === "left" ? "Previous step" : "Next step"}
|
|
142
|
+
>
|
|
143
|
+
<i
|
|
144
|
+
className={cn(
|
|
145
|
+
"text-[18px]",
|
|
146
|
+
dir === "left" ? "ri-arrow-left-s-line" : "ri-arrow-right-s-line",
|
|
147
|
+
)}
|
|
148
|
+
/>
|
|
149
|
+
</Button>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `FormBuilder.Back` — chevron to the previous step; disabled on the first. */
|
|
154
|
+
export function Back() {
|
|
155
|
+
const { goToPrevious, isFirstStep } = useStepper();
|
|
156
|
+
return <StepNavButton dir="left" onClick={goToPrevious} disabled={isFirstStep} />;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `FormBuilder.Next` — chevron to the next step (validates first); disabled on the last. */
|
|
160
|
+
export function Next() {
|
|
161
|
+
const { goToNext, isLastStep } = useStepper();
|
|
162
|
+
return <StepNavButton dir="right" onClick={() => void goToNext()} disabled={isLastStep} />;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Stepper action bar (Back/Next + divider, then the Submit) ────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `FormRenderer` wraps its `actions` in this. When the form is a stepper it prepends the
|
|
169
|
+
* chevron `Back`/`Next` controls + a divider before the (user-provided) Submit — the Figma
|
|
170
|
+
* `Body-HeaderBar-1.0` layout. Outside a stepper there's no `StepperContext`, so it renders
|
|
171
|
+
* the actions untouched.
|
|
172
|
+
*/
|
|
173
|
+
export function StepperActions({ children }: { children?: React.ReactNode }) {
|
|
174
|
+
const stepper = React.useContext(StepperContext);
|
|
175
|
+
if (!stepper) return <>{children}</>;
|
|
176
|
+
return (
|
|
177
|
+
<div className="flex items-center gap-2">
|
|
178
|
+
<Back />
|
|
179
|
+
<Next />
|
|
180
|
+
{/* Divider — white-alpha hairline between the nav and the Submit. */}
|
|
181
|
+
<span aria-hidden className="mx-1 h-5 w-px rounded-[2px] bg-white-alpha-20" />
|
|
182
|
+
{children}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Stepper root (marker) + state hook ──────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export interface StepperProps {
|
|
190
|
+
children: React.ReactNode;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `FormBuilder.Stepper` — declares a wizard. It renders **nothing itself**: the
|
|
195
|
+
* FormBuilder root detects it, lifts its state (so the nav can live in its own
|
|
196
|
+
* grid column *outside* the `<form>`), and renders `[nav | active step's fields]`.
|
|
197
|
+
* Children are `FormBuilder.Step`s (plus an optional custom footer).
|
|
198
|
+
*
|
|
199
|
+
* Every step's fields stay registered with react-hook-form at all times — the
|
|
200
|
+
* stepper only controls which step is *visible*. **Navigation is the step buttons
|
|
201
|
+
* themselves**: click a step to move there. Backward is free; clicking forward
|
|
202
|
+
* validates every step in between and stops at the first with errors (which shows a
|
|
203
|
+
* red indicator). Only the last step shows the Submit button.
|
|
204
|
+
*/
|
|
205
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- children are read by the FormBuilder root
|
|
206
|
+
export function Stepper(_props: StepperProps) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
(Stepper as unknown as { __isFormStepper: boolean }).__isFormStepper = true;
|
|
210
|
+
|
|
211
|
+
export function isStepperElement(node: React.ReactNode): node is React.ReactElement<StepperProps> {
|
|
212
|
+
return (
|
|
213
|
+
React.isValidElement(node) &&
|
|
214
|
+
(node.type as { __isFormStepper?: boolean })?.__isFormStepper === true
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type TriggerFn = (names?: FieldPath<FieldValues>[]) => Promise<boolean>;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Stepper state — lifted to the FormBuilder root so the nav (rail) and the step
|
|
222
|
+
* fields can render in separate grid columns while sharing one state. `trigger` is
|
|
223
|
+
* the form's `trigger` (passed in — no `useFormContext` needed). Inert when
|
|
224
|
+
* `steps` is empty (a form without a stepper still calls this, for hooks order).
|
|
225
|
+
*/
|
|
226
|
+
export function useStepperState(
|
|
227
|
+
steps: React.ReactElement<StepProps>[],
|
|
228
|
+
trigger: TriggerFn,
|
|
229
|
+
): StepperContextValue {
|
|
230
|
+
const [currentStep, setCurrentStep] = React.useState(0);
|
|
231
|
+
// Steps that have passed their last validation — kept so their checkmark persists when the
|
|
232
|
+
// user navigates back to an earlier step.
|
|
233
|
+
const [completedSteps, setCompletedSteps] = React.useState<Set<number>>(new Set());
|
|
234
|
+
const stepFieldsRef = React.useRef<Record<number, Set<string>>>({});
|
|
235
|
+
const titles = steps.map((s) => s.props.title);
|
|
236
|
+
const lastIndex = steps.length - 1;
|
|
237
|
+
|
|
238
|
+
const markStep = (step: number, passed: boolean) =>
|
|
239
|
+
setCompletedSteps((prev) => {
|
|
240
|
+
if (passed === prev.has(step)) return prev; // no change
|
|
241
|
+
const next = new Set(prev);
|
|
242
|
+
if (passed) next.add(step);
|
|
243
|
+
else next.delete(step);
|
|
244
|
+
return next;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const validateStep = async (step: number) => {
|
|
248
|
+
const names = [...(stepFieldsRef.current[step] ?? [])] as FieldPath<FieldValues>[];
|
|
249
|
+
const passed = names.length === 0 ? true : await trigger(names);
|
|
250
|
+
markStep(step, passed);
|
|
251
|
+
return passed;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Navigation runs through the step buttons. Backward is free; going forward
|
|
255
|
+
// validates every step between the current one and the target, and stops at
|
|
256
|
+
// the first step that has errors (so you can't skip past an invalid step).
|
|
257
|
+
const goToStep = async (index: number) => {
|
|
258
|
+
const target = Math.max(0, Math.min(index, Math.max(0, lastIndex)));
|
|
259
|
+
if (target <= currentStep) {
|
|
260
|
+
setCurrentStep(target);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
for (let s = currentStep; s < target; s++) {
|
|
264
|
+
if (!(await validateStep(s))) {
|
|
265
|
+
setCurrentStep(s);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
setCurrentStep(target);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const goToNext = async () => {
|
|
273
|
+
if (await validateStep(currentStep)) setCurrentStep((s) => Math.min(s + 1, lastIndex));
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
currentStep,
|
|
278
|
+
totalSteps: steps.length,
|
|
279
|
+
titles,
|
|
280
|
+
isFirstStep: currentStep === 0,
|
|
281
|
+
isLastStep: currentStep === lastIndex,
|
|
282
|
+
goToNext,
|
|
283
|
+
goToPrevious: () => setCurrentStep((s) => Math.max(s - 1, 0)),
|
|
284
|
+
goToStep,
|
|
285
|
+
stepFields: stepFieldsRef.current,
|
|
286
|
+
completedSteps,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { StepperNav, StepSlot };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "../Button";
|
|
6
|
+
import { useFormId, useLoading } from "./context";
|
|
7
|
+
|
|
8
|
+
export interface SubmitButtonProps {
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
loadingText?: ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* The form to submit, by `id`. Defaults to the enclosing FormBuilder's own form id (via
|
|
14
|
+
* context), so a bare `<FormBuilder.Submit>` works even in a header / action bar rendered
|
|
15
|
+
* **outside** the `<form>` element. Set it explicitly only to target a different form.
|
|
16
|
+
*/
|
|
17
|
+
form?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `FormBuilder.Submit` — a loading-aware submit button, hidden in view mode. */
|
|
21
|
+
export function SubmitButton({ children, className, loadingText, form }: SubmitButtonProps) {
|
|
22
|
+
const loading = useLoading();
|
|
23
|
+
const ctxFormId = useFormId();
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Button
|
|
27
|
+
type="submit"
|
|
28
|
+
// Defaults to the enclosing form's id, so a Save in the header (outside the `<form>`)
|
|
29
|
+
// still submits it via native form-association.
|
|
30
|
+
form={form ?? ctxFormId}
|
|
31
|
+
variant="PrimeStyle"
|
|
32
|
+
is_loading={loading}
|
|
33
|
+
className={className}
|
|
34
|
+
>
|
|
35
|
+
{loading ? (loadingText ?? children ?? "Saving…") : (children ?? "Save")}
|
|
36
|
+
</Button>
|
|
37
|
+
);
|
|
38
|
+
}
|