Multi-step wizard
A two-step wizard sharing one FormProvider across steps. Step navigation is local recipe state; field values and validation stay owned by FormProvider — the thin layer is not extended for step state. Per-step Next validates only that step’s fields before advancing.
Preview
Source
apps/docs/app/recipes/multi-step-wizard/recipe.tsx'use client';
import { useState } from 'react';
import { Button, FormField, FormProvider, Input, useForm } from '@hey-mike/tungsten';
const required = (v: unknown) => (v ? undefined : 'Required');
const isEmail = (v: unknown) =>
typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? undefined : 'Enter a valid email';
// Step navigation is LOCAL recipe state — FormProvider is NOT extended to know
// about steps (ADR-0003 ceiling). The single provider spans both steps so all
// values collect into one submit; "Next" validates just the current step's
// fields via validateField before advancing.
function WizardSteps() {
const form = useForm();
const [step, setStep] = useState(0);
const next = () => {
const stepFields = step === 0 ? ['fullName', 'email'] : [];
const ok = stepFields.every((name) => !form.validateField(name));
if (ok) setStep((s) => s + 1);
};
return (
<form onSubmit={form.handleSubmit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<p className="text-ink-3 text-2xs font-mono uppercase tracking-label">
Step {step + 1} of 2
</p>
{step === 0 && (
<>
<FormField name="fullName" label="Full name" required validate={required}>
<Input autoComplete="name" />
</FormField>
<FormField name="email" label="Email" required validate={isEmail}>
<Input type="email" autoComplete="email" />
</FormField>
<Button type="button" variant="primary" size="md" onClick={next}>
Next
</Button>
</>
)}
{step === 1 && (
<>
<FormField name="company" label="Company" validate={required} required>
<Input autoComplete="organization" />
</FormField>
<FormField name="role" label="Role">
<Input />
</FormField>
<div className="flex gap-3">
<Button type="button" variant="ghost" size="md" onClick={() => setStep(0)}>
Back
</Button>
<Button type="submit" variant="brand" size="md">
Finish
</Button>
</div>
</>
)}
</form>
);
}
export default function MultiStepWizardRecipe() {
return (
<FormProvider>
<WizardSteps />
</FormProvider>
);
}
'use client';
import { useState } from 'react';
import { Button, FormField, FormProvider, Input, useForm } from '@hey-mike/tungsten';
const required = (v: unknown) => (v ? undefined : 'Required');
const isEmail = (v: unknown) =>
typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? undefined : 'Enter a valid email';
// Step navigation is LOCAL recipe state — FormProvider is NOT extended to know
// about steps (ADR-0003 ceiling). The single provider spans both steps so all
// values collect into one submit; "Next" validates just the current step's
// fields via validateField before advancing.
function WizardSteps() {
const form = useForm();
const [step, setStep] = useState(0);
const next = () => {
const stepFields = step === 0 ? ['fullName', 'email'] : [];
const ok = stepFields.every((name) => !form.validateField(name));
if (ok) setStep((s) => s + 1);
};
return (
<form onSubmit={form.handleSubmit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<p className="text-ink-3 text-2xs font-mono uppercase tracking-label">
Step {step + 1} of 2
</p>
{step === 0 && (
<>
<FormField name="fullName" label="Full name" required validate={required}>
<Input autoComplete="name" />
</FormField>
<FormField name="email" label="Email" required validate={isEmail}>
<Input type="email" autoComplete="email" />
</FormField>
<Button type="button" variant="primary" size="md" onClick={next}>
Next
</Button>
</>
)}
{step === 1 && (
<>
<FormField name="company" label="Company" validate={required} required>
<Input autoComplete="organization" />
</FormField>
<FormField name="role" label="Role">
<Input />
</FormField>
<div className="flex gap-3">
<Button type="button" variant="ghost" size="md" onClick={() => setStep(0)}>
Back
</Button>
<Button type="submit" variant="brand" size="md">
Finish
</Button>
</div>
</>
)}
</form>
);
}
export default function MultiStepWizardRecipe() {
return (
<FormProvider>
<WizardSteps />
</FormProvider>
);
}