"use client"; import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode, } from "react"; import { AnimatePresence, motion, useAnimate } from "motion/react"; import { useReducedMotion } from "../motion/Preferences.tsx"; import { EASE_EXPO } from "../motion/easings.ts"; import { Button } from "../ui/Button.tsx"; import { useAnnouncer, useControllable } from "./internal.ts"; export type InterviewAnswer = string | number | boolean | readonly string[] | undefined; export type InterviewAnswers = Readonly>; export type InterviewQuestion = { id: string; title: string; description?: ReactNode; type: | "text" | "long" | "email" | "number" | "choice" | "multi" | "scale" | "yesno"; options?: readonly { value: string; label: string }[]; required?: boolean; placeholder?: string; /** Scale and number bounds. */ min?: number; max?: number; /** Return a message to keep the question open. */ validate?: ( value: InterviewAnswer, answers: InterviewAnswers, ) => string | undefined; /** Branching: the next question's id, or "review" to finish early. Defaults to the following question. */ next?: (answers: InterviewAnswers) => string | undefined; }; const KEYS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const empty = (v: InterviewAnswer) => v === undefined || v === "" || (Array.isArray(v) && v.length === 0) || (typeof v === "number" && !Number.isFinite(v)); function display(q: InterviewQuestion, v: InterviewAnswer) { if (empty(v)) return "—"; if (q.type === "yesno") return v ? "Yes" : "No"; const label = (value: string) => q.options?.find((o) => o.value === value)?.label ?? value; if (Array.isArray(v)) return v.map(label).join(", "); if (q.type === "choice") return label(String(v)); return String(v); } /** * A conversation instead of a form: one question at a time, letter keys for * choices, branching, a review before sending and an honest submit. */ export function Interview({ label, questions, answers: answersProp, defaultAnswers = {}, onAnswersChange, onSubmit, intro, done = "Thank you — your answers are in.", submitLabel = "Send answers", className = "", style, }: { label: string; questions: readonly InterviewQuestion[]; answers?: InterviewAnswers; defaultAnswers?: InterviewAnswers; onAnswersChange?: (answers: InterviewAnswers) => void; /** Resolve to finish; throw to offer a retry. */ onSubmit: (answers: InterviewAnswers) => void | Promise; /** An optional welcome screen before the first question. */ intro?: { title: string; body?: ReactNode; start?: string }; done?: ReactNode; submitLabel?: string; className?: string; style?: CSSProperties; }) { const id = useId(); const reduce = useReducedMotion(); const [message, announce] = useAnnouncer(); const [answers, setAnswers] = useControllable( answersProp, defaultAnswers, onAnswersChange, ); const [path, setPath] = useState( intro || !questions.length ? [] : [questions[0]!.id], ); const [screen, setScreen] = useState< "intro" | "question" | "review" | "sending" | "failed" | "done" >(intro ? "intro" : "question"); const [direction, setDirection] = useState(1); const [error, setError] = useState(null); const [confirming, setConfirming] = useState(null); const [scope, animate] = useAnimate(); const advanceTimer = useRef(0); useEffect(() => () => window.clearTimeout(advanceTimer.current), []); const current = questions.find((q) => q.id === path[path.length - 1]); const index = current ? questions.indexOf(current) : -1; const remaining = index === -1 ? 0 : questions.length - index - 1; const progress = screen === "done" || screen === "review" || screen === "sending" || screen === "failed" ? 1 : path.length ? (path.length - 1) / (path.length + remaining) : 0; const value = current ? answers[current.id] : undefined; const set = (v: InterviewAnswer) => { if (!current) return; setError(null); setAnswers({ ...answers, [current.id]: v }); }; const reject = (text: string) => { setError(text); announce(text); if (!reduce && scope.current) void animate( scope.current, { x: [0, -8, 7, -4, 2, 0] }, { duration: 0.42 }, ); }; const forward = (withValue: InterviewAnswer = value) => { if (!current) return; if (current.required && empty(withValue)) return reject("This one needs an answer to continue."); if ( current.type === "email" && !empty(withValue) && !/^\S+@\S+\.\S+$/.test(String(withValue)) ) return reject("That doesn’t look like an email address."); if ( current.type === "number" && typeof withValue === "number" && ((current.min !== undefined && withValue < current.min) || (current.max !== undefined && withValue > current.max)) ) return reject( current.min !== undefined && current.max !== undefined ? `Choose a number from ${current.min} to ${current.max}.` : current.min !== undefined ? `Choose ${current.min} or more.` : `Choose ${current.max} or less.`, ); const problem = current.validate?.(withValue, { ...answers, [current.id]: withValue, }); if (problem) return reject(problem); const after = { ...answers, [current.id]: withValue }; const target = current.next?.(after) ?? questions[index + 1]?.id ?? "review"; setDirection(1); setError(null); if (target === "review" || !questions.some((q) => q.id === target)) { setScreen("review"); announce("Review your answers."); } else { setPath([...path, target]); announce( `Question ${path.length + 1}: ${questions.find((q) => q.id === target)!.title}`, ); } }; const back = () => { setDirection(-1); setError(null); if (screen === "review") setScreen("question"); else if (path.length > 1) setPath(path.slice(0, -1)); else if (intro) setScreen("intro"); }; const choose = (option: string) => { if (!current) return; if (current.type === "multi") { const list = Array.isArray(value) ? (value as string[]) : []; set( list.includes(option) ? list.filter((v) => v !== option) : [...list, option], ); return; } set(option); setConfirming(option); window.clearTimeout(advanceTimer.current); advanceTimer.current = window.setTimeout( () => { setConfirming(null); forward(option); }, reduce ? 0 : 420, ); }; const submit = async () => { setScreen("sending"); try { await onSubmit(answers); setScreen("done"); announce("Answers sent."); } catch { setScreen("failed"); announce("Sending failed. Your answers are still here."); } }; // Shortcuts: letters pick choices, digits pick a scale, Y and N answer yes/no, Enter continues. const onKey = (e: KeyboardEvent) => { if (screen !== "question" || !current || e.metaKey || e.altKey) return; const typing = (e.target as HTMLElement).matches( "input:not([type=radio]):not([type=checkbox]), textarea", ); if ( e.key === "Enter" && (!typing || current.type !== "long" || e.ctrlKey) ) { e.preventDefault(); if (!e.repeat) forward(); return; } if (typing || e.ctrlKey) return; const k = e.key.toUpperCase(); if ( (current.type === "choice" || current.type === "multi") && current.options ) { const option = current.options[KEYS.indexOf(k)]; if (option && k.length === 1) { e.preventDefault(); choose(option.value); } } else if (current.type === "yesno" && (k === "Y" || k === "N")) { e.preventDefault(); set(k === "Y"); setConfirming(k); window.clearTimeout(advanceTimer.current); advanceTimer.current = window.setTimeout( () => { setConfirming(null); forward(k === "Y"); }, reduce ? 0 : 420, ); } else if (current.type === "scale" && /^[0-9]$/.test(e.key)) { const n = e.key === "0" ? 10 : Number(e.key); if (n >= (current.min ?? 1) && n <= (current.max ?? 10)) { e.preventDefault(); set(n); } } }; const slide = reduce ? { initial: false as const, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: 0 } }, } : { initial: { opacity: 0, y: 40 * direction }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -28 * direction }, }; const transition = reduce ? { duration: 0 } : { duration: 0.5, ease: EASE_EXPO }; const view = screen === "question" ? (current?.id ?? "question") : screen === "sending" || screen === "failed" ? "review" : screen; return (
{screen === "intro" && intro && (

{intro.title}

{intro.body && (
{intro.body}
)}

{questions.length} questions · Enter ↵ to continue

)} {screen === "question" && current && (
{ e.preventDefault(); forward(); }} >

{current.title} {current.required && ( * )}

{current.description && (
{current.description}
)}
{(path.length > 1 || intro) && ( )} {current.type === "long" ? ( <> Ctrl ↵ to continue ) : current.type === "choice" || current.type === "multi" ? ( <> Press A– {KEYS[(current.options?.length ?? 1) - 1]} {current.type === "multi" && ( <> {" "} then Enter ↵ )} ) : current.type === "yesno" ? ( <> Press Y or N ) : ( <> Enter ↵ to continue )}
)} {(screen === "review" || screen === "sending" || screen === "failed") && (

Before you send

{path.map((qid) => { const q = questions.find((x) => x.id === qid)!; return (
{q.title}
{display(q, answers[qid])}
); })}
{screen === "failed" && (

Sending didn’t work. Nothing is lost — try again.

)}
)} {screen === "done" && (
)}
{message}
); } function Field({ question: q, value, confirming, labelledBy, describedBy, invalid, onChange, onChoose, }: { question: InterviewQuestion; value: InterviewAnswer; confirming: string | null; labelledBy: string; describedBy?: string; invalid: boolean; onChange: (value: InterviewAnswer) => void; onChoose: (value: string) => void; }) { const first = useRef(null); useEffect(() => { const frame = requestAnimationFrame(() => first.current?.focus({ preventScroll: true }), ); return () => cancelAnimationFrame(frame); }, [q.id]); const common = { "aria-labelledby": labelledBy, "aria-describedby": describedBy, "aria-invalid": invalid || undefined, }; const setFirst = (el: HTMLElement | null) => { if (el && !first.current) first.current = el; }; if (q.type === "choice" || q.type === "multi") return (
{q.options?.map((o, i) => { const on = Array.isArray(value) ? value.includes(o.value) : value === o.value; return (