"use client"; import { Loader2 } from "lucide-react"; import { AnimatePresence, type AnimationControls, motion, useAnimationControls, useReducedMotion, type Variants, } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { Button } from "@/components/motion/button"; import { Input } from "@/components/motion/input"; import { EASE_OUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type StepFormStep = | { id: string; kind: "text"; /** Small mono, uppercase, wide-tracking label above the title. */ eyebrow?: string; title: string; hint?: string; placeholder?: string; inputType?: "text" | "email"; required?: boolean; /** Return an error message to block continuing, or null when valid. */ validate?: (value: string) => string | null; } | { id: string; kind: "choice"; eyebrow?: string; title: string; hint?: string; options: { value: string; label: string; description?: string }[]; required?: boolean; }; export interface StepFormProps { steps: StepFormStep[]; onComplete?: (values: Record) => void | Promise; /** Default "All set". */ successTitle?: string; successMessage?: string; /** Provide to show a ghost "restart" button on the success screen. */ onRestart?: () => void; className?: string; } type StepDirection = 1 | -1; type FormStatus = "active" | "submitting" | "success"; /** Horizontal travel for the directional step swap — spec-mandated 24px. */ const STEP_SLIDE_OFFSET = 24; /** Pause after picking an option, long enough to register the choice before advancing. */ const CHOICE_AUTO_ADVANCE_MS = 250; const stepVariants: Variants = { enter: (direction: StepDirection) => ({ opacity: 0, x: direction * STEP_SLIDE_OFFSET, }), center: { opacity: 1, x: 0, transition: { duration: 0.3, ease: EASE_OUT }, }, exit: (direction: StepDirection) => ({ opacity: 0, x: direction * -STEP_SLIDE_OFFSET, transition: { duration: 0.18, ease: EASE_OUT }, }), }; function pad(value: number) { return String(value).padStart(2, "0"); } /** Mirrors the shake feel from input.tsx's own error effect. */ function shake(controls: AnimationControls, reduce: boolean) { if (reduce) return; controls.start({ x: [0, -6, 6, -4, 4, -2, 0], transition: { duration: 0.45 }, }); } function SegmentedProgress({ steps, activeIndex, reduce, }: { steps: StepFormStep[]; activeIndex: number; reduce: boolean; }) { return (
{steps.map((step, index) => { const filled = index <= activeIndex; return ( ); })}
); } function SuccessCheck({ reduce }: { reduce: boolean }) { return ( ); } /** Shared enter/animate/exit for the two outer views (form ⇄ success). */ function outerViewMotionProps(reduce: boolean) { return { initial: reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }, animate: reduce ? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT } } : { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.24, ease: EASE_OUT }, }, exit: reduce ? { opacity: 0, transition: { duration: 0.14, ease: EASE_OUT } } : { opacity: 0, y: -8, filter: "blur(4px)", transition: { duration: 0.16, ease: EASE_OUT }, }, } as const; } export function StepForm({ steps, onComplete, successTitle = "All set", successMessage, onRestart, className, }: StepFormProps) { const reduce = useReducedMotion(); const baseId = useId(); const fieldId = `${baseId}-field`; const errorId = `${baseId}-error`; const [stepIndex, setStepIndex] = useState(0); const [direction, setDirection] = useState(1); const [values, setValues] = useState>({}); const [fieldError, setFieldError] = useState(null); const [status, setStatus] = useState("active"); const inputRef = useRef(null); const choiceShake = useAnimationControls(); const choiceTimerRef = useRef | null>(null); const skipFocusRef = useRef(true); const currentStep = steps[stepIndex]; const isLastStep = stepIndex === steps.length - 1; const currentValue = currentStep ? (values[currentStep.id] ?? "") : ""; const submitting = status === "submitting"; const clearChoiceTimer = () => { if (choiceTimerRef.current !== null) { clearTimeout(choiceTimerRef.current); choiceTimerRef.current = null; } }; // Cancel any pending auto-advance if the form unmounts mid-pause. useEffect(() => { return () => { if (choiceTimerRef.current !== null) clearTimeout(choiceTimerRef.current); }; }, []); // Focus the field on every step arrival except the very first paint, so // landing on the form never steals focus but advancing keeps you typing. // Re-reads `steps[stepIndex]` instead of closing over `currentStep` so a // run between two consecutive "text" steps still re-triggers on index // change (kind alone wouldn't differ). useEffect(() => { if (skipFocusRef.current) { skipFocusRef.current = false; return; } const step = steps[stepIndex]; if (status !== "active" || step?.kind !== "text") return; const timeoutId = setTimeout( () => inputRef.current?.focus({ preventScroll: true }), reduce ? 0 : 260, ); return () => clearTimeout(timeoutId); }, [stepIndex, status, reduce, steps]); if (!currentStep) return null; const goToStep = (nextIndex: number, dir: StepDirection) => { clearChoiceTimer(); setFieldError(null); setDirection(dir); setStepIndex(nextIndex); }; const goBack = () => { if (stepIndex === 0) return; goToStep(stepIndex - 1, -1); }; const handleComplete = async () => { setStatus("submitting"); try { await onComplete?.(values); setStatus("success"); } catch { // No error UX is specified for a rejected onComplete — fall back to // the editable step so the user can retry instead of getting stuck. setStatus("active"); } }; const advance = () => { if (isLastStep) { void handleComplete(); return; } goToStep(stepIndex + 1, 1); }; const handleValueChange = (next: string) => { setValues((prev) => ({ ...prev, [currentStep.id]: next })); setFieldError(null); }; const handleContinue = () => { if (submitting) return; if (currentStep.kind === "text") { const trimmed = currentValue.trim(); if (currentStep.required && trimmed.length === 0) { setFieldError("This field is required."); return; } const validationError = trimmed.length > 0 ? currentStep.validate?.(currentValue) : null; if (validationError) { setFieldError(validationError); return; } } else if (currentStep.required && !currentValue) { setFieldError("Choose an option to continue."); shake(choiceShake, !!reduce); return; } advance(); }; const handleChoiceSelect = (value: string) => { if (submitting) return; handleValueChange(value); clearChoiceTimer(); choiceTimerRef.current = setTimeout(() => { choiceTimerRef.current = null; advance(); }, CHOICE_AUTO_ADVANCE_MS); }; const titleClassName = cn( "block text-2xl font-semibold leading-snug text-foreground", currentStep.eyebrow && "mt-2", ); return ( {status === "success" ? (

{successTitle}

{successMessage ? (

{successMessage}

) : null} {onRestart ? ( ) : null}
) : (
{pad(stepIndex + 1)} / {pad(steps.length)}
{currentStep.eyebrow ? (

{currentStep.eyebrow}

) : null} {currentStep.kind === "text" ? ( ) : (

{currentStep.title}

)} {currentStep.hint ? (

{currentStep.hint}

) : null}
{currentStep.kind === "text" ? ( { if (event.key === "Enter") { event.preventDefault(); handleContinue(); } }} error={Boolean(fieldError)} aria-describedby={fieldError ? errorId : undefined} disabled={submitting} /> ) : ( {currentStep.options.map((option) => { const selected = currentValue === option.value; return ( ); })} )}
{fieldError ? ( {fieldError} ) : null}
{stepIndex > 0 ? ( ) : null}
)}
); }