
Bars version of Signup Wizard: email and password, profile, preference checkboxes and a welcome step, validated with react-hook-form and zod and tracked by plain rail segments.
blocks/stepper/signup-wizard-bars/index.tsx
"use client"
import { useId, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import {
Stepper,
StepperContent,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
} from "@/components/stepper/stepper"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// The copy: change text here, not in the markup.
const COPY = {
title: "Create your account",
back: "Back",
next: "Continue",
finish: "Open workspace",
steps: [
{ title: "Account", body: "Your email and a password." },
{ title: "Profile", body: "How your team sees you." },
{ title: "Preferences", body: "You can change these later." },
{ title: "Done", body: "Your workspace is ready." },
],
email: "Email",
emailError: "Enter a valid email address.",
password: "Password",
passwordHint: "At least twelve characters.",
passwordError: "Use at least twelve characters.",
name: "Display name",
nameError: "Enter a display name.",
role: "Role",
prefs: [
"Weekly digest by email",
"Keyboard shortcuts on",
"Dark mode follows the system",
],
done: (name: string) =>
`Welcome, ${name}. Orbcore Design is set up with you as its first admin.`,
opened: "Opening your workspace. Hand this event to your router.",
} as const
const schema = z.object({
email: z.email(COPY.emailError),
password: z.string().min(12, COPY.passwordError),
name: z.string().trim().min(1, COPY.nameError),
role: z.string(),
prefs: z.array(z.boolean()),
})
type Values = z.infer<typeof schema>
// The fields each step must pass before Continue; Enter submits the step.
const FIELDS: (keyof Values)[][] = [
["email", "password"],
["name", "role"],
["prefs"],
[],
]
// The bars twin of signup-wizard: same panels, copy and validation; the rail is segments.
export default function SignupWizardBars() {
const id = useId()
const [step, setStep] = useState(0)
const [opened, setOpened] = useState(false)
const last = COPY.steps.length - 1
const current = COPY.steps[step]
const form = useForm<Values>({
resolver: zodResolver(schema),
mode: "onTouched",
defaultValues: {
email: "",
password: "",
name: "",
role: "",
prefs: [true, false, true],
},
})
// Validate only the open step, then advance; the last step opens the workspace.
const advance = async () => {
if (!(await form.trigger(FIELDS[step]))) return
if (step < last) setStep(step + 1)
else setOpened(true)
}
return (
<form
className="mx-auto w-full max-w-md"
onSubmit={(e) => {
e.preventDefault()
void advance()
}}
>
<Stepper value={step} onValueChange={setStep} variant="bars">
<StepperList>
{COPY.steps.map((s, i) => (
<StepperItem key={s.title} value={i}>
<StepperSeparator />
<StepperTitle className="sr-only">{s.title}</StepperTitle>
</StepperItem>
))}
</StepperList>
<h2 className="text-xl font-semibold tracking-tight">
{step === 0 ? COPY.title : current.title}
</h2>
<p className="-mt-4 text-sm text-muted-foreground">{current.body}</p>
<StepperContent keepMounted value={0}>
<FieldGroup>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}su-email`}>
{COPY.email}
</FieldLabel>
<Input
{...field}
id={`${id}su-email`}
type="email"
autoComplete="email"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}su-pass`}>
{COPY.password}
</FieldLabel>
<Input
{...field}
id={`${id}su-pass`}
type="password"
autoComplete="new-password"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? (
<FieldError errors={[fieldState.error]} />
) : (
<FieldDescription>{COPY.passwordHint}</FieldDescription>
)}
</Field>
)}
/>
</FieldGroup>
</StepperContent>
<StepperContent keepMounted value={1}>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}su-name`}>{COPY.name}</FieldLabel>
<Input
{...field}
id={`${id}su-name`}
autoComplete="nickname"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor={`${id}su-role`}>{COPY.role}</FieldLabel>
<Input
{...field}
id={`${id}su-role`}
placeholder="Designer"
/>
</Field>
)}
/>
</FieldGroup>
</StepperContent>
<StepperContent keepMounted value={2}>
<FieldGroup>
{COPY.prefs.map((p, i) => (
<Controller
key={p}
name={`prefs.${i}` as const}
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id={`${id}su-pref-${i}`}
checked={field.value}
onCheckedChange={(v) => field.onChange(Boolean(v))}
/>
<FieldLabel
htmlFor={`${id}su-pref-${i}`}
className="font-normal"
>
{p}
</FieldLabel>
</Field>
)}
/>
))}
</FieldGroup>
</StepperContent>
<StepperContent value={3}>
<p className="text-sm" aria-live="polite">
{opened
? COPY.opened
: COPY.done(form.getValues("name") || "there")}
</p>
</StepperContent>
<div className="flex justify-between">
<Button
type="button"
variant="outline"
disabled={step === 0 || opened}
onClick={() => setStep(step - 1)}
>
{COPY.back}
</Button>
<Button type="submit" disabled={opened}>
{step < last ? COPY.next : COPY.finish}
</Button>
</div>
</Stepper>
</form>
)
}