"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import "./progressive-form.css"; /** * Progressive Form * One question at a time, and every answer is written into a sentence above * the field. While you type, your words appear in the sentence in pencil; * Continue inks them in. Answered words are buttons that take you back. When * the last blank is filled the sentence is the review: read it, fix a word, * confirm. */ export type StepOption = { value: string; label: string; description?: string; disabled?: boolean }; export type Step = { id: string; /** The question, shown above the field. */ label: string; hint?: string; type: "text" | "email" | "number" | "choice" | "date"; placeholder?: string; options?: StepOption[]; min?: number; max?: number; /** Return an error message, or null when the value is acceptable. */ validate?: (value: string) => string | null; /** How the value reads inside the sentence. */ summary?: (value: string) => string; /** Label for the review list. */ name: string; }; type Props = { title: string; steps: Step[]; /** Compose the sentence; call slot(id) where each answer belongs. */ template: (slot: (id: string) => ReactNode) => ReactNode; onSubmit: (values: Record) => Promise | void; submitLabel?: string; successMessage?: ReactNode; initialValues?: Record; theme?: "paper" | "night"; }; export function ProgressiveForm({ title, steps, template, onSubmit, submitLabel = "Confirm", successMessage = "Done.", initialValues = {}, theme = "paper" }: Props) { const [values, setValues] = useState>(initialValues); const [inked, setInked] = useState>({}); const [index, setIndex] = useState(0); const [error, setError] = useState(null); const [status, setStatus] = useState<"editing" | "submitting" | "done">("editing"); const [returnToReview, setReturnToReview] = useState(false); const fieldRef = useRef(null); const reviewRef = useRef(null); const firstRender = useRef(true); const titleId = useId(); const errorId = useId(); const hintId = useId(); const reviewing = index >= steps.length; const step = steps[Math.min(index, steps.length - 1)]; const value = values[step.id] ?? ""; const read = (s: Step, v: string) => (s.summary ? s.summary(v) : s.options?.find((o) => o.value === v)?.label ?? v); // Move focus to the new question (or the review heading) whenever the step changes. useLayoutEffect(() => { if (firstRender.current) { firstRender.current = false; return; } if (reviewing) return reviewRef.current?.focus({ preventScroll: true }); const root = fieldRef.current; const target = root?.querySelector('input[type="radio"]:checked') ?? root?.querySelector('input:not([type="radio"]), input[type="radio"]:not(:disabled)'); target?.focus({ preventScroll: true }); }, [index, reviewing]); const check = (s: Step, v: string): string | null => { if (!v.trim()) return s.type === "choice" ? "Choose one to continue." : "This one's needed to continue."; if (s.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return "That doesn't look like an email address."; if (s.type === "number") { const n = Number(v); if (!Number.isInteger(n)) return "Use a whole number."; if (s.min !== undefined && n < s.min) return `At least ${s.min}.`; if (s.max !== undefined && n > s.max) return `No more than ${s.max}.`; } return s.validate?.(v) ?? null; }; const next = (e?: FormEvent) => { e?.preventDefault(); if (status !== "editing") return; const problem = check(step, value); if (problem) { setError(problem); return; } setError(null); setInked((k) => ({ ...k, [step.id]: true })); if (returnToReview && steps.every((s) => s.id === step.id || !check(s, values[s.id] ?? ""))) { setReturnToReview(false); setIndex(steps.length); return; } setIndex((i) => i + 1); }; const goTo = (i: number) => { if (status !== "editing") return; setError(null); if (reviewing) setReturnToReview(true); setIndex(i); }; const back = () => goTo(Math.max(0, index - 1)); const submit = async () => { setStatus("submitting"); try { await onSubmit(values); setStatus("done"); } catch { setStatus("editing"); setError("Something went wrong. Try again."); } }; const set = (v: string) => { setValues((vs) => ({ ...vs, [step.id]: v })); if (error) setError(null); setInked((k) => (k[step.id] ? { ...k, [step.id]: false } : k)); }; /** A slot in the sentence: blank, pencilled (being answered) or inked (committed). */ const slot = (id: string) => { const i = steps.findIndex((s) => s.id === id); const s = steps[i]; if (!s) return null; const v = values[id] ?? ""; const current = !reviewing && i === index; const isInked = inked[id] && !!v; const state = current ? (error ? "error" : v ? "pencil" : "blank-current") : isInked ? "ink" : v ? "pencil" : "blank"; if (state === "ink" && status === "editing") { return ( // A span with button semantics, so a long answer wraps like the words around it. goTo(i)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goTo(i); } }} aria-label={`${s.name}: ${read(s, v)}. Edit`} > {read(s, v)} ); } if (state === "ink" || state === "pencil") { return ( {read(s, v)} ); } return ( {s.name}, not yet answered ); }; const answered = steps.filter((s) => inked[s.id]).length; return (

{title}

{status === "done" ? "Complete" : reviewing ? "Review" : `Step ${index + 1} of ${steps.length}`}

{template(slot)}

{!reviewing ? (
{step.hint && !error && (

{step.hint}

)} {error && ( )}
{index > 0 && ( )}
) : (

{status === "done" ? successMessage : "Does that read right?"}

{steps.map((s, i) => (
{s.name}
{s.options?.find((o) => o.value === values[s.id])?.label ?? read(s, values[s.id] ?? "")}
{status === "editing" && ( )}
))}
{error && (

{error}

)} {status !== "done" && (
)}
)}
); } /* ─── Fields ────────────────────────────────────────────────────────── */ function Field({ step, value, onChange, invalid, describedBy }: { step: Step; value: string; onChange: (v: string) => void; invalid: boolean; describedBy?: string }) { const id = useId(); const common = { "aria-invalid": invalid || undefined, "aria-describedby": describedBy }; if (step.type === "choice") { return (
{step.label}
{step.options?.map((o) => ( ))}
); } if (step.type === "number") { const n = Number(value || 0); const bump = (d: number) => onChange(String(Math.max(step.min ?? 0, Math.min(step.max ?? Infinity, (Number.isFinite(n) ? n : 0) + d)))); return (
onChange(e.target.value.replace(/[^\d]/g, ""))} onKeyDown={(e) => { if (e.key === "ArrowUp") (e.preventDefault(), bump(1)); if (e.key === "ArrowDown") (e.preventDefault(), bump(-1)); }} {...common} />
); } return (
onChange(e.target.value)} {...common} />
); } export function useToday() { const [today, setToday] = useState(""); useEffect(() => setToday(new Date().toISOString().slice(0, 10)), []); return today; }