diff --git a/apps/apollo-vertex/app/components/form-wizard/form-wizard-headless-demo.tsx b/apps/apollo-vertex/app/components/form-wizard/form-wizard-headless-demo.tsx new file mode 100644 index 000000000..87de9fec5 --- /dev/null +++ b/apps/apollo-vertex/app/components/form-wizard/form-wizard-headless-demo.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { formOptions } from "@tanstack/react-form"; +import { useState } from "react"; +import { z } from "zod"; +import { FieldGroup } from "@/components/ui/field"; +import { withForm } from "@/components/ui/form"; +import { + FormWizard, + FormWizardNav, + FormWizardStep, + FormWizardSteps, + useFormWizard, + useFormWizardContext, + type WizardStepDef, +} from "@/components/ui/form-wizard"; +import { cn } from "@/lib/utils"; +import { LocaleProvider } from "@/registry/shell/shell-locale-provider"; + +const accountSchema = z.object({ + fullName: z.string().min(2, "Enter your name."), + email: z.email("Enter a valid email."), +}); +const planSchema = z.object({ + tier: z.string().min(1), +}); +const reviewSchema = z.object({ + acceptTerms: z.boolean().refine((value) => value, { + message: "Accept the terms to continue.", + }), +}); + +const wizardSchema = z.object({ + account: accountSchema, + plan: planSchema, + review: reviewSchema, +}); + +type WizardValues = z.infer; + +const defaultValues = { + account: { fullName: "", email: "" }, + plan: { tier: "free" }, + review: { acceptTerms: false }, +}; + +const wizardOpts = formOptions({ defaultValues }); + +const tierOptions = [ + { label: "Free", value: "free" }, + { label: "Pro", value: "pro" }, +]; + +const steps: WizardStepDef[] = [ + { id: "account", title: "Account" }, + { id: "plan", title: "Plan" }, + { id: "review", title: "Review" }, +]; + +// A custom nav rendered entirely by the consumer via the render-prop escape hatch. +function CustomNav() { + return ( + + {({ back, isFirst, isLast, form }) => ( +
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +
+ )} +
+ ); +} + +const AccountStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
{ + event.preventDefault(); + event.stopPropagation(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + +
+ )} +
+ ); + }, +}); + +const PlanStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
{ + event.preventDefault(); + event.stopPropagation(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + +
+ )} +
+ ); + }, +}); + +const ReviewStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
{ + event.preventDefault(); + event.stopPropagation(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + +
+ )} +
+ ); + }, +}); + +export function FormWizardHeadlessDemo() { + const [submitted, setSubmitted] = useState(null); + + const wizard = useFormWizard({ + formOptions: wizardOpts, + schema: wizardSchema, + steps, + onSubmit: (values) => setSubmitted(values), + }); + + return ( + + + {/* Custom progress rail built from the FormWizardSteps render prop. */} + + {({ steps: visibleSteps, stepIndex, goToStep }) => ( +
+
+ {visibleSteps.map((step, index) => { + const done = index < stepIndex; + const active = index === stepIndex; + return ( + + ); + })} +
+
+
+
+
+ )} + + + + + + + + + + + + + + {submitted ? ( +
+          {JSON.stringify(submitted, null, 2)}
+        
+ ) : null} + + ); +} diff --git a/apps/apollo-vertex/app/components/form-wizard/page.mdx b/apps/apollo-vertex/app/components/form-wizard/page.mdx index c1a1c6c9e..0d152de04 100644 --- a/apps/apollo-vertex/app/components/form-wizard/page.mdx +++ b/apps/apollo-vertex/app/components/form-wizard/page.mdx @@ -1,4 +1,5 @@ import { FormWizardDemo } from './form-wizard-demo'; +import { FormWizardHeadlessDemo } from './form-wizard-headless-demo'; # Form Wizard @@ -114,6 +115,39 @@ function SignUpWizard() { } ``` +## Bring your own UI + +`useFormWizard` is the headless engine: it renders nothing and returns the `form`, the visible `steps`, navigation helpers, and derived flags (`stepIndex`, `isFirst`, `isLast`, `progress`). The styled `FormWizardSteps` and `FormWizardNav` are one recipe on top of it. Consumers get three levels of control. + +1. **Defaults.** Drop in `` and `` for the Apollo look. +2. **Restyle with render props.** Pass a function child to keep the wiring but own the markup. `FormWizardSteps` calls it with `{ steps, stepIndex, goToStep }`, and `FormWizardNav` with `{ back, next, isFirst, isLast, form }`. +3. **Fully headless.** Ignore the styled parts and drive everything from the `useFormWizard` return value directly. + +`FormWizard` and `FormWizardStep` also accept `asChild` to swap their wrapper element. + +The demo below replaces the Stepper with a custom progress rail and the nav with custom buttons, using the render-prop escape hatches: + +
+ +
+ +```tsx +// Keep the wiring, own the design. + + {({ steps, stepIndex, goToStep }) => ( + + )} + + + + {({ back, next, isFirst, isLast }) => ( + + )} + +``` + +The default nav's "Next" button is a submit that runs the current step's `FormGroup` validation before `onGroupSubmit` calls `next()`. A custom nav that calls `next()` directly skips that per-step validation, so drive advancement through a submit button (as the demo does) when you want the same guardrails. + ## Step definition | Field | Type | Notes | diff --git a/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx b/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx index b17577200..5df5ba8fd 100644 --- a/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx +++ b/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx @@ -1,17 +1,31 @@ "use client"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useFormWizardContext } from "./form-wizard"; -type FormWizardNavProps = ComponentProps<"div">; +interface FormWizardNavRenderApi { + back: () => void; + next: () => void; + isFirst: boolean; + isLast: boolean; + form: ReturnType["form"]; +} + +interface FormWizardNavProps extends Omit, "children"> { + children?: (api: FormWizardNavRenderApi) => ReactNode; +} -function FormWizardNav({ className, ...props }: FormWizardNavProps) { - const { form, isFirst, isLast, back } = useFormWizardContext(); +function FormWizardNav({ className, children, ...props }: FormWizardNavProps) { + const { form, isFirst, isLast, back, next } = useFormWizardContext(); const { t } = useTranslation(); + if (typeof children === "function") { + return <>{children({ back, next, isFirst, isLast, form })}; + } + return (
> extends ComponentProps<"div"> { wizard: ReturnType>; + asChild?: boolean; } function FormWizard>({ wizard, className, children, + asChild = false, ...props }: FormWizardProps) { + const Comp = asChild ? Slot : "div"; return ( // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- context is field-type agnostic; parts read only step metadata and generic helpers -
{children} -
+
); } +interface FormWizardStepsRenderApi { + steps: FormWizardApi["steps"]; + stepIndex: number; + goToStep: (id: string) => void; +} + interface FormWizardStepsProps - extends Omit, "activeStep"> { + extends Omit, "activeStep" | "children"> { clickable?: boolean; + children?: (api: FormWizardStepsRenderApi) => ReactNode; } function FormWizardSteps({ clickable, className, + children, ...props }: FormWizardStepsProps) { const { steps, stepIndex, goToStep } = useFormWizardContext(); + if (typeof children === "function") { + return <>{children({ steps, stepIndex, goToStep })}; + } + return ( {steps.map((step, index) => { @@ -91,27 +112,35 @@ function FormWizardSteps({ interface FormWizardStepProps extends ComponentProps<"div"> { stepId: string; + asChild?: boolean; } function FormWizardStep({ stepId, className, children, + asChild = false, ...props }: FormWizardStepProps) { const { currentStepId } = useFormWizardContext(); if (stepId !== currentStepId) return null; + const Comp = asChild ? Slot : "div"; return ( -
{children} -
+ ); } export { FormWizard, FormWizardStep, FormWizardSteps, useFormWizardContext }; -export type { FormWizardProps, FormWizardStepProps, FormWizardStepsProps }; +export type { + FormWizardProps, + FormWizardStepProps, + FormWizardStepsProps, + FormWizardStepsRenderApi, +}; diff --git a/apps/apollo-vertex/registry/form-wizard/index.ts b/apps/apollo-vertex/registry/form-wizard/index.ts index 9052b2356..defa3ca31 100644 --- a/apps/apollo-vertex/registry/form-wizard/index.ts +++ b/apps/apollo-vertex/registry/form-wizard/index.ts @@ -5,9 +5,14 @@ export { type FormWizardStepProps, FormWizardSteps, type FormWizardStepsProps, + type FormWizardStepsRenderApi, useFormWizardContext, } from "./form-wizard"; -export { FormWizardNav, type FormWizardNavProps } from "./form-wizard-nav"; +export { + FormWizardNav, + type FormWizardNavProps, + type FormWizardNavRenderApi, +} from "./form-wizard-nav"; export { useFormWizard, type UseFormWizardOptions,