Fazekit

Code

"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, "&quot;")}"></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;

More in Productivity

View all →