"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { Check, Code2, Copy, Eye, Hammer, Inbox, Link2, ListOrdered, RotateCcw, Rows3, Share2, SlidersHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { FieldPalette, FormCanvas, type DragData } from "./builder";
import { DEFAULT_FORM, DEFAULT_RESPONSES, FIELD_META, THEMES, createField, uid } from "./data";
import { FieldSettings } from "./field-settings";
import { FIELD_ICONS, FIELD_TONE } from "./icons";
import { toCsv } from "./logic";
import { ClassicForm, FocusForm, ThemedSurface } from "./preview";
import { ResponsesView } from "./responses";
import type { Answers, FieldType, FormDoc, FormField, FormResponse, FormTheme } from "./types";
import { Button, Modal, Segmented, Toasts, copyText, downloadFile, inputCls, useToasts } from "./ui";
import { useSortableDrag } from "./use-sortable-drag";
export type { Answers, FieldType, FormDoc, FormField, FormResponse, FormTheme } from "./types";
export interface FormBuilderAppProps {
/** The form being edited. Defaults to a seeded product-feedback survey. */
initialForm?: FormDoc;
/** Existing responses. Defaults to 38 seeded responses. */
initialResponses?: FormResponse[];
/** Available themes. */
themes?: FormTheme[];
/** Tab to open first. */
initialTab?: Tab;
/** Fires after every edit to the form definition. */
onChange?: (form: FormDoc) => void;
/** Fires when someone submits the live preview. */
onSubmit?: (response: FormResponse) => void;
/** Fires with the CSV text when responses are exported. */
onExportCsv?: (csv: string) => void;
className?: string;
}
type Tab = "build" | "preview" | "responses";
export function FormBuilderApp(props: FormBuilderAppProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", props.className)}>
<Builder {...props} rootRef={rootRef} />
</div>
</MotionConfig>
);
}
function Builder({
initialForm = DEFAULT_FORM,
initialResponses = DEFAULT_RESPONSES,
themes = THEMES,
initialTab = "build",
onChange,
onSubmit,
onExportCsv,
rootRef,
}: FormBuilderAppProps & { rootRef: React.RefObject<HTMLDivElement | null> }) {
const [form, setForm] = React.useState<FormDoc>(initialForm);
const [responses, setResponses] = React.useState<FormResponse[]>(initialResponses);
const [tab, setTab] = React.useState<Tab>(initialTab);
const [selectedId, setSelectedId] = React.useState<string | null>(initialForm.fields[4]?.id ?? null);
const [pane, setPane] = React.useState<"questions" | "settings">("questions");
const [mode, setMode] = React.useState<"classic" | "focus">("focus");
const [runKey, setRunKey] = React.useState(0);
const [share, setShare] = React.useState(false);
const [newCount, setNewCount] = React.useState(0);
const { toasts, push, dismiss } = useToasts();
const reduced = useReducedMotion() ?? false;
const theme = themes.find((t) => t.id === form.themeId) ?? themes[0];
const selected = form.fields.find((f) => f.id === selectedId) ?? null;
const first = React.useRef(true);
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(form);
}, [form]);
/* ------------------------------- mutations ------------------------------ */
const patchForm = (patch: Partial<FormDoc>) => setForm((f) => ({ ...f, ...patch }));
const patchField = (id: string, patch: Partial<FormField>) => setForm((f) => ({ ...f, fields: f.fields.map((x) => (x.id === id ? { ...x, ...patch } : x)) }));
const focusField = (id: string) =>
requestAnimationFrame(() => {
const el = rootRef.current?.querySelector<HTMLElement>(`[data-field-id="${CSS.escape(id)}"]`);
el?.scrollIntoView({ block: "nearest", behavior: reduced ? "auto" : "smooth" });
el?.querySelector<HTMLElement>("[data-drag-handle]")?.focus({ preventScroll: true });
});
const addField = (type: FieldType, index?: number) => {
const field = createField(type);
setForm((f) => {
const fields = [...f.fields];
const sel = selectedId ? fields.findIndex((x) => x.id === selectedId) : -1;
fields.splice(index ?? (sel >= 0 ? sel + 1 : fields.length), 0, field);
return { ...f, fields };
});
setSelectedId(field.id);
requestAnimationFrame(() => {
const input = rootRef.current?.querySelector<HTMLInputElement>(`#fl-${CSS.escape(field.id)}`);
input?.scrollIntoView({ block: "nearest", behavior: reduced ? "auto" : "smooth" });
input?.focus({ preventScroll: true });
input?.select();
});
};
const moveTo = (id: string, index: number) =>
setForm((f) => {
const from = f.fields.findIndex((x) => x.id === id);
if (from < 0) return f;
const fields = f.fields.filter((x) => x.id !== id);
fields.splice(Math.max(0, Math.min(index, fields.length)), 0, f.fields[from]);
// A condition may only reference an earlier question.
const fixed = fields.map((x, i) => (x.condition && fields.findIndex((p) => p.id === x.condition?.fieldId) >= i ? { ...x, condition: null } : x));
return { ...f, fields: fixed };
});
const move = (id: string, dir: -1 | 1) => {
const i = form.fields.findIndex((f) => f.id === id);
if (i + dir < 0 || i + dir >= form.fields.length) return;
moveTo(id, i + dir);
focusField(id);
};
const duplicate = (id: string) => {
const src = form.fields.find((f) => f.id === id);
if (!src) return;
const copy: FormField = { ...structuredClone(src), id: uid("q"), label: `${src.label} (copy)` };
setForm((f) => {
const fields = [...f.fields];
fields.splice(fields.findIndex((x) => x.id === id) + 1, 0, copy);
return { ...f, fields };
});
setSelectedId(copy.id);
focusField(copy.id);
};
const remove = (id: string) => {
const i = form.fields.findIndex((f) => f.id === id);
const dependents = form.fields.filter((f) => f.condition?.fieldId === id).length;
setForm((f) => ({ ...f, fields: f.fields.filter((x) => x.id !== id).map((x) => (x.condition?.fieldId === id ? { ...x, condition: null } : x)) }));
setSelectedId(null);
const next = form.fields[i + 1] ?? form.fields[i - 1];
if (next) focusField(next.id);
push("Question deleted", dependents ? `${dependents} dependent condition${dependents > 1 ? "s were" : " was"} removed.` : undefined);
};
/* ---------------------------------- drag -------------------------------- */
const { drag, overlayX, overlayY, onPointerDown, registerList, consumeClick } = useSortableDrag<DragData>({
rootRef,
reducedMotion: reduced,
reach: 80,
onDrop: (d, t) => {
if (d.data.kind === "new") addField(d.data.type, t.index);
else {
moveTo(d.data.id, t.index);
setSelectedId(d.data.id);
}
},
});
/* ------------------------------- responses ------------------------------ */
const handleSubmit = (answers: Answers, duration: number) => {
const r: FormResponse = { id: uid("resp"), submittedAt: new Date().toISOString(), duration, answers };
setResponses((rs) => [r, ...rs]);
setNewCount((n) => n + 1);
onSubmit?.(r);
push("Response recorded", "See it in the Responses tab.");
};
const exportCsv = () => {
const csv = toCsv(form.fields, responses);
downloadFile(`${form.title.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "responses"}.csv`, csv, "text/csv");
onExportCsv?.(csv);
push("CSV exported", `${responses.length} responses · ${form.fields.length} columns`);
};
const draggedField = drag?.data.kind === "field" ? form.fields.find((f) => drag.data.kind === "field" && f.id === drag.data.id) : null;
const tabs: { id: Tab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ id: "build", label: "Build", icon: Hammer },
{ id: "preview", label: "Preview", icon: Eye },
{ id: "responses", label: "Responses", icon: Inbox },
];
return (
<>
<header className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-3 py-2.5 sm:px-4 md:h-14 md:flex-nowrap md:py-0">
<span aria-hidden className="grid size-7 shrink-0 place-items-center rounded-lg text-white shadow-sm" style={{ background: `linear-gradient(135deg, ${theme.accent}, color-mix(in oklab, ${theme.accent} 60%, #000))` }}>
<ListOrdered className="size-4" />
</span>
<div className="min-w-0 flex-1 md:flex-none">
<p className="truncate text-sm font-semibold md:max-w-56">{form.title || "Untitled form"}</p>
<p className="text-[11px] text-muted-foreground">
<span className="mr-1 inline-block size-1.5 rounded-full bg-emerald-500 align-middle" aria-hidden />
Published · {form.fields.length} questions
</p>
</div>
<nav role="tablist" aria-label="Form sections" className="order-last flex w-full rounded-lg bg-muted p-0.5 md:order-none md:mx-auto md:w-auto dark:bg-muted/60">
{tabs.map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={on}
aria-controls={`fb-panel-${t.id}`}
id={`fb-tab-${t.id}`}
onClick={() => {
setTab(t.id);
if (t.id === "responses") setNewCount(0);
}}
className={cn(
"relative flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md px-3 text-[13px] font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-ring md:flex-none",
on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{on && <motion.span layoutId="fb-tab" className="absolute inset-0 rounded-md bg-background shadow-sm dark:bg-card" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<t.icon className="relative size-3.5" />
<span className="relative">{t.label}</span>
{t.id === "responses" && (
<span className={cn("relative rounded-full px-1.5 text-[10px] font-semibold tabular-nums", newCount ? "bg-primary text-primary-foreground" : "bg-foreground/10")}>{responses.length}</span>
)}
</button>
);
})}
</nav>
<Button onClick={() => setShare(true)} variant="primary" className="shrink-0">
<Share2 className="size-3.5" /> Share
</Button>
</header>
<div className="relative min-h-0 flex-1" style={{ ["--fa" as string]: theme.accent }}>
<AnimatePresence mode="wait" initial={false}>
{tab === "build" && (
<motion.div
key="build"
id="fb-panel-build"
role="tabpanel"
aria-labelledby="fb-tab-build"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.16 }}
className={cn("flex h-full flex-col", drag && "cursor-grabbing [&_*]:cursor-grabbing")}
>
<div className="border-b px-3 py-2 lg:hidden">
<Segmented
label="Builder pane"
value={pane}
onChange={setPane}
options={[
{ value: "questions", label: <><Rows3 className="size-3.5" /> Questions</> },
{ value: "settings", label: <><SlidersHorizontal className="size-3.5" /> {selected ? `Edit Q${form.fields.indexOf(selected) + 1}` : "Form settings"}</> },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<aside aria-label="Question types" className="hidden w-56 shrink-0 flex-col border-r bg-card/40 lg:flex">
<div className="border-b px-4 py-3">
<p className="text-[13px] font-semibold">Add a question</p>
<p className="text-[11px] text-muted-foreground">Drag onto the form or click</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FieldPalette onPointerDown={onPointerDown} dragging={Boolean(drag)} onAdd={(t) => addField(t)} />
</div>
<p className="border-t px-4 py-3 text-[11px] leading-relaxed text-muted-foreground">
<kbd className="rounded border bg-muted px-1 font-mono">Alt</kbd> + <kbd className="rounded border bg-muted px-1 font-mono">↑↓</kbd> reorders the focused question.
</p>
</aside>
<main className={cn("min-w-0 flex-1 lg:block", pane === "questions" ? "block" : "hidden")} aria-label="Form questions">
<FormCanvas
form={form}
theme={theme}
selectedId={selectedId}
drag={drag}
registerList={registerList}
onPointerDown={onPointerDown}
consumeClick={consumeClick}
onSelect={setSelectedId}
onForm={(p) => patchForm(p)}
onField={(id, p) => patchField(id, p)}
onMove={move}
onDuplicate={duplicate}
onDelete={remove}
onAdd={(t) => addField(t)}
/>
</main>
<aside aria-label="Question settings" className={cn("w-full shrink-0 border-l bg-card/40 lg:block lg:w-80", pane === "settings" ? "block" : "hidden")}>
<FieldSettings form={form} field={selected} onField={(id, p) => patchField(id, p)} onForm={(p) => patchForm(p)} onDelete={remove} />
</aside>
</div>
</motion.div>
)}
{tab === "preview" && (
<motion.div
key="preview"
id="fb-panel-preview"
role="tabpanel"
aria-labelledby="fb-tab-preview"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.16 }}
className="flex h-full flex-col"
>
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2 sm:px-4">
<Segmented
label="Preview mode"
value={mode}
onChange={(m) => {
setMode(m);
setRunKey((k) => k + 1);
}}
className="w-full sm:w-72"
options={[
{ value: "focus", label: "One at a time" },
{ value: "classic", label: "Classic form" },
]}
/>
<div role="radiogroup" aria-label="Theme" className="flex items-center gap-1 sm:ml-2">
{themes.map((t) => (
<button
key={t.id}
type="button"
role="radio"
aria-checked={t.id === theme.id}
aria-label={`${t.name} theme`}
title={t.name}
onClick={() => patchForm({ themeId: t.id })}
className={cn(
"size-6 rounded-full outline-none transition hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
t.id === theme.id && "ring-2 ring-foreground/70 ring-offset-2 ring-offset-background",
)}
style={{ background: t.accent }}
/>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => setRunKey((k) => k + 1)} className="ml-auto">
<RotateCcw className="size-3.5" /> Restart
</Button>
</div>
<div className="min-h-0 flex-1">
<ThemedSurface theme={theme} className={mode === "classic" ? "overflow-y-auto" : ""}>
{mode === "focus" ? (
<FocusForm key={`f-${runKey}`} form={form} theme={theme} onSubmit={handleSubmit} />
) : (
<ClassicForm key={`c-${runKey}`} form={form} theme={theme} onSubmit={handleSubmit} />
)}
</ThemedSurface>
</div>
</motion.div>
)}
{tab === "responses" && (
<motion.div
key="responses"
id="fb-panel-responses"
role="tabpanel"
aria-labelledby="fb-tab-responses"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.16 }}
className="h-full"
>
<ResponsesView form={form} responses={responses} onExport={exportCsv} />
</motion.div>
)}
</AnimatePresence>
</div>
{drag && (
<motion.div aria-hidden className="pointer-events-none absolute left-0 top-0 z-50" style={{ x: overlayX, y: overlayY, width: drag.width, ["--fa" as string]: theme.accent }}>
<motion.div
animate={drag.settling ? { scale: 1, rotate: 0 } : { scale: 1.02, rotate: drag.data.kind === "new" ? -2 : 1 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={cn("rounded-2xl border border-[var(--fa)] bg-card p-3 ring-4 ring-[color-mix(in_oklab,var(--fa)_15%,transparent)]", drag.settling ? "shadow-md" : "shadow-2xl shadow-black/20 dark:shadow-black/60", !drag.over && "opacity-70")}
>
<DragPreview type={drag.data.kind === "new" ? drag.data.type : (draggedField?.type ?? "short_text")} label={drag.data.kind === "new" ? FIELD_META[drag.data.type].label : (draggedField?.label ?? "")} />
</motion.div>
</motion.div>
)}
<ShareDialog open={share} onClose={() => setShare(false)} form={form} onCopied={(what) => push(`${what} copied`, what === "Link" ? form.shareUrl : undefined)} />
<Toasts toasts={toasts} onDismiss={dismiss} />
</>
);
}
function DragPreview({ type, label }: { type: FieldType; label: string }) {
const Icon = FIELD_ICONS[type];
return (
<div className="flex items-center gap-2.5">
<span className={cn("grid size-8 shrink-0 place-items-center rounded-lg", FIELD_TONE[type])}>
<Icon className="size-4" />
</span>
<span className="min-w-0 truncate text-[13px] font-medium">{label || "Untitled question"}</span>
</div>
);
}
function ShareDialog({ open, onClose, form, onCopied }: { open: boolean; onClose: () => void; form: FormDoc; onCopied: (what: string) => void }) {
const [copied, setCopied] = React.useState<string | null>(null);
const embed = `<iframe src="${form.shareUrl}?embed=1" width="100%" height="640" style="border:0;border-radius:12px" title="${form.title.replace(/"/g, """)}"></iframe>`;
const copy = async (what: string, text: string) => {
if (await copyText(text)) {
setCopied(what);
onCopied(what);
window.setTimeout(() => setCopied(null), 1600);
}
};
return (
<Modal open={open} onClose={onClose} size="sm" title="Share your form" description="Anyone with the link can respond.">
<div className="space-y-5 p-5">
<div className="space-y-1.5">
<label htmlFor="fb-link" className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<Link2 className="size-3" /> Public link
</label>
<div className="flex gap-2">
<input id="fb-link" readOnly value={form.shareUrl} onFocus={(e) => e.currentTarget.select()} className={cn(inputCls, "h-9 font-mono text-xs")} />
<Button variant="primary" data-autofocus onClick={() => copy("Link", form.shareUrl)} className="h-9">
{copied === "Link" ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied === "Link" ? "Copied" : "Copy"}
</Button>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<Code2 className="size-3" /> Embed code
</p>
<button type="button" onClick={() => copy("Embed code", embed)} className="rounded px-1.5 text-xs font-medium text-primary outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring">
{copied === "Embed code" ? "Copied" : "Copy"}
</button>
</div>
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-3 font-mono text-[11px] leading-relaxed text-zinc-300">{embed}</pre>
</div>
<div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-3">
<QrMark text={form.shareUrl} />
<p className="text-xs text-muted-foreground">Print the QR code on flyers or slides so people can respond from their phone.</p>
</div>
</div>
</Modal>
);
}
/** Decorative QR-style mark (finder patterns + a hash of the link) — not a scannable code. */
function QrMark({ text }: { text: string }) {
const n = 21;
let h = 2166136261;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
const cells: [number, number][] = [];
const finder = (x: number, y: number) => x < 7 && y < 7;
for (let y = 0; y < n; y++)
for (let x = 0; x < n; x++) {
const inF = finder(x, y) || finder(n - 1 - x, y) || finder(x, n - 1 - y);
if (inF) {
const fx = x < 7 ? x : n - 1 - x;
const fy = y < 7 ? y : n - 1 - y;
const ring = Math.max(Math.abs(fx - 3), Math.abs(fy - 3));
if (ring !== 2) cells.push([x, y]);
continue;
}
if (x === 7 || y === 7 || x === n - 8 || y === n - 8) continue;
h = Math.imul(h ^ (x * 31 + y * 17), 2654435761);
if ((h >>> 13) & 1) cells.push([x, y]);
}
return (
<svg viewBox={`-1 -1 ${n + 2} ${n + 2}`} className="size-16 shrink-0 rounded-md bg-white" aria-hidden shapeRendering="crispEdges">
{cells.map(([x, y]) => (
<rect key={`${x}-${y}`} x={x} y={y} width="1" height="1" fill="#18181b" />
))}
</svg>
);
}
export default FormBuilderApp;