A reusable form builder block with drag-and-drop fields, JSON definitions, previews, and keyed response data.
"use client"
import * as React from "react"
import { createPortal } from "react-dom"
import {
CalendarIcon,
EyeIcon,
FileTextIcon,
GripVerticalIcon,
HashIcon,
LayoutPanelTopIcon,
ListChecksIcon,
MailIcon,
PanelRightIcon,
Rows3Icon,
SplitSquareHorizontalIcon,
TextCursorInputIcon,
ToggleLeftIcon,
Trash2Icon,
} from "lucide-react"
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
rectIntersection,
useDraggable,
useDroppable,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
type Modifier,
} from "@dnd-kit/core"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { DatePickerField } from "./date-picker-field"
import { Stepper } from "./stepper"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
export type FormFieldType =
| "text"
| "textarea"
| "number"
| "email"
| "date"
| "datetime"
| "select"
| "multi-select"
| "checkbox"
| "checkbox-group"
| "static-text"
| "page-break"
export type FormVisibilityOperator =
| "equals"
| "not-equals"
| "contains"
| "not-contains"
| "is-empty"
| "is-not-empty"
export type FormVisibilityRule = {
targetField: string
dependsOnField: string
operator: FormVisibilityOperator
value?: unknown
}
export type FormField = {
id: string
key: string
label: string
type: FormFieldType
required?: boolean
helpText?: string
placeholder?: string
options?: string[]
defaultValue?: unknown
layout?: { row: number; col: number }
stepLabel?: string
}
export type FormActor = {
id: string
name: string
email?: string
}
export type FormDefinitionStatus = "draft" | "published" | "archived"
export type FormDefinitionMetadata = {
author?: FormActor
createdAt?: string
modifiedAt?: string
modifiedBy?: FormActor
publishedAt?: string
status?: FormDefinitionStatus
}
export type FormDefinition = {
id?: string
version?: number
title?: string
description?: string
metadata?: FormDefinitionMetadata
fields: FormField[]
visibilityRules?: FormVisibilityRule[]
}
export type FormResponses = Record<string, unknown>
export type FormResponseStatus = "draft" | "submitted" | "archived"
export type FormResponseMetadata = {
createdAt: string
createdBy?: FormActor
modifiedAt: string
modifiedBy?: FormActor
submittedAt?: string
submittedBy?: FormActor
status: FormResponseStatus
}
export type FormResponseRecord = {
id: string
formId: string
formVersion: number
definitionSnapshot: FormDefinition
responses: FormResponses
metadata: FormResponseMetadata
}
export type FormResponseRenderMode = "submitted-version" | "latest-version"
export type FormPage = {
id: string
label: string
fields: FormField[]
}
const fieldTypes: Array<{
type: FormFieldType
label: string
icon: React.ComponentType<{ className?: string }>
}> = [
{ type: "text", label: "Text", icon: TextCursorInputIcon },
{ type: "textarea", label: "Long text", icon: Rows3Icon },
{ type: "number", label: "Number", icon: HashIcon },
{ type: "email", label: "Email", icon: MailIcon },
{ type: "date", label: "Date", icon: CalendarIcon },
{ type: "datetime", label: "Date time", icon: CalendarIcon },
{ type: "select", label: "Select", icon: SplitSquareHorizontalIcon },
{ type: "multi-select", label: "Multi-select", icon: ListChecksIcon },
{ type: "checkbox", label: "Checkbox", icon: ToggleLeftIcon },
{ type: "checkbox-group", label: "Checkbox group", icon: ListChecksIcon },
{ type: "static-text", label: "Text block", icon: FileTextIcon },
{ type: "page-break", label: "Page break", icon: LayoutPanelTopIcon },
]
const visibilityOperators: Array<{
value: FormVisibilityOperator
label: string
}> = [
{ value: "equals", label: "Equals" },
{ value: "not-equals", label: "Does not equal" },
{ value: "contains", label: "Contains" },
{ value: "not-contains", label: "Does not contain" },
{ value: "is-empty", label: "Is empty" },
{ value: "is-not-empty", label: "Is not empty" },
]
function createId(prefix = "field") {
return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
}
function toFieldKey(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
}
function uniqueFieldKey(definition: FormDefinition, type: FormFieldType) {
const base = type.replace(/-/g, "_")
let index = definition.fields.length + 1
while (definition.fields.some((field) => field.key === `${base}_${index}`)) {
index += 1
}
return `${base}_${index}`
}
export function createFormField(
type: FormFieldType,
definition: FormDefinition = { fields: [] }
): FormField {
const typeMeta = fieldTypes.find((item) => item.type === type)
const label = typeMeta?.label ?? "Field"
const key = uniqueFieldKey(definition, type)
return {
id: createId(),
key,
label,
type,
required: false,
placeholder:
type === "text" || type === "email" || type === "number"
? `Enter ${label.toLowerCase()}`
: undefined,
options:
type === "select" ||
type === "multi-select" ||
type === "checkbox-group"
? ["Option 1", "Option 2"]
: undefined,
defaultValue:
type === "static-text"
? "Add supporting instructions or section context."
: undefined,
stepLabel: type === "page-break" ? "" : undefined,
}
}
function orderedFields(fields: FormField[]) {
return [...fields]
.map((field, index) => ({
field,
index,
row: field.layout?.row ?? index,
col: field.layout?.col ?? 0,
}))
.sort((left, right) => left.row - right.row || left.col - right.col || left.index - right.index)
.map((item) => item.field)
}
function fieldRows(fields: FormField[]) {
const rows: FormField[][] = []
let currentRow: FormField[] = []
function flushRow() {
if (currentRow.length === 0) return
rows.push(currentRow)
currentRow = []
}
for (const field of orderedFields(fields)) {
if (field.type === "page-break") {
flushRow()
rows.push([field])
continue
}
currentRow.push(field)
if (currentRow.length === 3) flushRow()
}
flushRow()
return rows
}
export function normalizeFormDefinition(
definition: FormDefinition
): FormDefinition {
const ordered = orderedFields(definition.fields)
const fields: FormField[] = []
let hasDataInPage = false
for (const field of ordered) {
if (field.type !== "page-break") {
fields.push(field)
hasDataInPage = true
continue
}
if (!hasDataInPage) continue
fields.push({
...field,
required: false,
options: undefined,
defaultValue: undefined,
})
hasDataInPage = false
}
while (fields.at(-1)?.type === "page-break") fields.pop()
return {
...definition,
fields: applyFieldLayout(fields),
visibilityRules: definition.visibilityRules ?? [],
}
}
function applyFieldLayout(fields: FormField[]) {
const nextFields: FormField[] = []
let row = 0
let col = 0
for (const field of fields) {
if (field.type === "page-break") {
if (col > 0) {
row += 1
col = 0
}
nextFields.push({ ...field, layout: { row, col: 0 } })
row += 1
col = 0
continue
}
nextFields.push({ ...field, layout: { row, col } })
col += 1
if (col >= 3) {
row += 1
col = 0
}
}
return nextFields
}
export function buildFormPages(definition: FormDefinition): FormPage[] {
const normalized = normalizeFormDefinition(definition)
const pages: FormPage[] = []
let currentFields: FormField[] = []
let nextLabel: string | undefined
function pushPage() {
if (currentFields.length === 0) return
const index = pages.length + 1
pages.push({
id: `page-${index}`,
label: nextLabel?.trim() || `Step ${index}`,
fields: currentFields,
})
currentFields = []
nextLabel = undefined
}
for (const field of orderedFields(normalized.fields)) {
if (field.type === "page-break") {
pushPage()
nextLabel = field.stepLabel
continue
}
currentFields.push(field)
}
pushPage()
return pages
}
function matchesVisibilityRule(
operator: FormVisibilityOperator,
actualValue: unknown,
expectedValue: unknown
): boolean {
if (operator === "equals") return actualValue === expectedValue
if (operator === "not-equals") return actualValue !== expectedValue
if (operator === "contains") {
if (Array.isArray(actualValue)) return actualValue.includes(expectedValue)
return String(actualValue ?? "").includes(String(expectedValue ?? ""))
}
if (operator === "not-contains") {
if (Array.isArray(actualValue)) return !actualValue.includes(expectedValue)
return !String(actualValue ?? "").includes(String(expectedValue ?? ""))
}
if (operator === "is-empty") {
return (
actualValue === undefined ||
actualValue === null ||
actualValue === "" ||
(Array.isArray(actualValue) && actualValue.length === 0)
)
}
if (operator === "is-not-empty") {
return !matchesVisibilityRule("is-empty", actualValue, expectedValue)
}
return true
}
function getFieldVisibility(
definition: FormDefinition,
responses: FormResponses
) {
const visibility: Record<string, boolean> = {}
for (const field of definition.fields) visibility[field.key] = true
for (const rule of definition.visibilityRules ?? []) {
visibility[rule.targetField] = matchesVisibilityRule(
rule.operator,
responses[rule.dependsOnField],
rule.value
)
}
return visibility
}
export function validateFormResponses(
definition: FormDefinition,
responses: FormResponses,
scopedFieldKeys?: string[]
) {
const errors: Record<string, string> = {}
const visibility = getFieldVisibility(definition, responses)
const scope = scopedFieldKeys ? new Set(scopedFieldKeys) : null
for (const field of definition.fields) {
if (scope && !scope.has(field.key)) continue
if (!visibility[field.key]) continue
if (field.type === "static-text" || field.type === "page-break") continue
if (!field.required) continue
const value = responses[field.key]
const isEmpty =
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0)
if (isEmpty) errors[field.key] = `${field.label} is required`
}
return errors
}
function initialResponses(definition: FormDefinition): FormResponses {
const responses: FormResponses = {}
for (const field of definition.fields) {
if (field.type === "static-text" || field.type === "page-break") continue
if (field.defaultValue !== undefined && field.defaultValue !== null) {
responses[field.key] = field.defaultValue
} else if (field.type === "checkbox") {
responses[field.key] = false
} else if (field.type === "multi-select" || field.type === "checkbox-group") {
responses[field.key] = []
}
}
return responses
}
function nowIso() {
return new Date().toISOString()
}
function formIdFor(definition: FormDefinition) {
return definition.id ?? (toFieldKey(definition.title ?? "form") || "form")
}
function formVersionFor(definition: FormDefinition) {
return definition.version ?? 1
}
export function createFormResponseRecord(
definition: FormDefinition,
responses: FormResponses,
options: {
id?: string
actor?: FormActor
createdAt?: string
submittedAt?: string
status?: FormResponseStatus
} = {}
): FormResponseRecord {
const timestamp = options.createdAt ?? nowIso()
const status = options.status ?? "submitted"
return {
id: options.id ?? createId("response"),
formId: formIdFor(definition),
formVersion: formVersionFor(definition),
definitionSnapshot: normalizeFormDefinition(definition),
responses,
metadata: {
createdAt: timestamp,
createdBy: options.actor,
modifiedAt: timestamp,
modifiedBy: options.actor,
submittedAt: status === "submitted" ? options.submittedAt ?? timestamp : undefined,
submittedBy: status === "submitted" ? options.actor : undefined,
status,
},
}
}
export function updateFormResponseRecord(
record: FormResponseRecord,
responses: FormResponses,
options: {
actor?: FormActor
modifiedAt?: string
status?: FormResponseStatus
} = {}
): FormResponseRecord {
const timestamp = options.modifiedAt ?? nowIso()
const status = options.status ?? record.metadata.status
return {
...record,
responses,
metadata: {
...record.metadata,
modifiedAt: timestamp,
modifiedBy: options.actor ?? record.metadata.modifiedBy,
submittedAt:
status === "submitted"
? record.metadata.submittedAt ?? timestamp
: record.metadata.submittedAt,
submittedBy:
status === "submitted"
? record.metadata.submittedBy ?? options.actor
: record.metadata.submittedBy,
status,
},
}
}
export function resolveFormDefinitionForResponse(
record: FormResponseRecord,
latestDefinition?: FormDefinition,
mode: FormResponseRenderMode = "submitted-version"
) {
if (mode === "latest-version" && latestDefinition) {
return normalizeFormDefinition(latestDefinition)
}
return normalizeFormDefinition(record.definitionSnapshot)
}
export function getFormResponseValues(
response: FormResponseRecord | FormResponses
): FormResponses {
if (
response &&
typeof response === "object" &&
"responses" in response &&
typeof response.responses === "object" &&
response.responses !== null
) {
return response.responses as FormResponses
}
return response as FormResponses
}
function useIsSmallScreen() {
const [isSmallScreen, setIsSmallScreen] = React.useState(false)
React.useEffect(() => {
const media = window.matchMedia("(max-width: 639px)")
const sync = () => setIsSmallScreen(media.matches)
sync()
media.addEventListener("change", sync)
return () => media.removeEventListener("change", sync)
}, [])
return isSmallScreen
}
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false)
React.useEffect(() => {
const media = window.matchMedia(query)
const sync = () => setMatches(media.matches)
sync()
media.addEventListener("change", sync)
return () => media.removeEventListener("change", sync)
}, [query])
return matches
}
const snapOverlayToCursor: Modifier = ({
activeNodeRect,
activatorEvent,
overlayNodeRect,
transform,
}) => {
if (!activeNodeRect || !overlayNodeRect || !activatorEvent) return transform
if (!("clientX" in activatorEvent) || !("clientY" in activatorEvent)) {
return transform
}
const { clientX, clientY } = activatorEvent
if (typeof clientX !== "number" || typeof clientY !== "number") {
return transform
}
return {
...transform,
x:
transform.x +
clientX -
activeNodeRect.left -
overlayNodeRect.width / 2,
y:
transform.y +
clientY -
activeNodeRect.top -
overlayNodeRect.height / 2,
}
}
const canvasAwareCollisionDetection: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args)
if (pointerCollisions.length > 0) return pointerCollisions
return rectIntersection(args)
}
function PaletteField({
type,
label,
icon: Icon,
}: {
type: FormFieldType
label: string
icon: React.ComponentType<{ className?: string }>
}) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, isDragging } =
useDraggable({ id: `palette:${type}` })
return (
<button
ref={setNodeRef}
type="button"
className="flex w-full items-center gap-2 rounded-md border bg-card px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
style={{ opacity: isDragging ? 0.4 : 1 }}
>
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="inline-flex cursor-grab touch-none select-none items-center justify-center rounded-sm p-0.5 text-muted-foreground active:cursor-grabbing"
>
<GripVerticalIcon className="size-4" />
</span>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</button>
)
}
function PaletteFieldPreview({
label,
icon: Icon,
}: {
type: FormFieldType
label: string
icon: React.ComponentType<{ className?: string }>
}) {
return (
<div className="flex w-72 items-center gap-2 rounded-md border bg-card px-3 py-2 text-left text-sm shadow-lg">
<span className="inline-flex touch-none select-none items-center justify-center rounded-sm p-0.5 text-muted-foreground">
<GripVerticalIcon className="size-4" />
</span>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</div>
)
}
function CanvasField({
field,
rowSize,
selected,
onSelect,
onDelete,
}: {
field: FormField
rowSize: number
selected: boolean
onSelect: () => void
onDelete: () => void
}) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, isDragging } =
useDraggable({ id: `field:${field.id}` })
const { setNodeRef: setBeforeDropRef, isOver: isOverBefore } = useDroppable({
id: `field:${field.id}:before`,
})
const { setNodeRef: setAfterDropRef, isOver: isOverAfter } = useDroppable({
id: `field:${field.id}:after`,
})
const isPageBreak = field.type === "page-break"
return (
<div className="relative">
<div
ref={setBeforeDropRef}
className={cn(
"pointer-events-none absolute z-10",
isPageBreak ? "inset-x-1 top-0 h-1/2" : "inset-y-1 left-0 w-1/2"
)}
aria-hidden="true"
/>
<div
ref={setAfterDropRef}
className={cn(
"pointer-events-none absolute z-10",
isPageBreak ? "inset-x-1 bottom-0 h-1/2" : "inset-y-1 right-0 w-1/2"
)}
aria-hidden="true"
/>
{isOverBefore ? (
<motion.div
layoutId="form-builder-drop-line"
className={cn(
"absolute z-20 rounded-full bg-primary",
isPageBreak ? "inset-x-2 -top-1 h-0.5" : "inset-y-2 left-0 w-0.5"
)}
/>
) : null}
{isOverAfter ? (
<motion.div
layoutId="form-builder-drop-line"
className={cn(
"absolute z-20 rounded-full bg-primary",
isPageBreak ? "inset-x-2 -bottom-1 h-0.5" : "inset-y-2 right-0 w-0.5"
)}
/>
) : null}
<div
ref={setNodeRef}
data-form-builder-node
data-row-size={rowSize}
role="button"
tabIndex={0}
className={cn(
"relative z-0 flex min-h-16 w-full items-center gap-2 rounded-md border p-3 text-left outline-none transition-colors",
selected
? "border-primary bg-muted/40"
: isPageBreak
? "border-dashed bg-muted/20"
: "bg-card hover:bg-muted/30"
)}
style={{ opacity: isDragging ? 0.45 : 1 }}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onSelect()
}
}}
>
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="inline-flex size-7 shrink-0 cursor-grab touch-none select-none items-center justify-center rounded-md text-muted-foreground hover:bg-muted active:cursor-grabbing"
>
<GripVerticalIcon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{isPageBreak ? field.stepLabel || "Page break" : field.label}
</div>
<div className="truncate text-xs text-muted-foreground">
{isPageBreak ? "Starts the next step" : field.key}
</div>
</div>
{rowSize < 3 ? (
<Badge variant="outline">{isPageBreak ? "step" : field.type}</Badge>
) : null}
<Button
type="button"
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={(event) => {
event.stopPropagation()
onDelete()
}}
>
<Trash2Icon className="size-4" />
<span className="sr-only">Delete field</span>
</Button>
</div>
</div>
)
}
function CanvasFieldPreview({ field }: { field: FormField }) {
const isPageBreak = field.type === "page-break"
return (
<div
className={cn(
"flex min-h-16 w-80 items-center gap-2 rounded-md border bg-card p-3 text-left shadow-lg",
isPageBreak && "border-dashed bg-muted/20"
)}
>
<span className="inline-flex size-7 shrink-0 touch-none select-none items-center justify-center rounded-md text-muted-foreground">
<GripVerticalIcon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{isPageBreak ? field.stepLabel || "Page break" : field.label}
</div>
<div className="truncate text-xs text-muted-foreground">
{isPageBreak ? "Starts the next step" : field.key}
</div>
</div>
<Badge variant="outline">{isPageBreak ? "step" : field.type}</Badge>
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground">
<Trash2Icon className="size-4" />
</span>
</div>
)
}
function FieldPalette() {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Field types</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{fieldTypes.map((item) => (
<PaletteField key={item.type} {...item} />
))}
</CardContent>
</Card>
)
}
function CanvasEndDropZone() {
const { setNodeRef, isOver } = useDroppable({ id: "form-canvas-end" })
return (
<div ref={setNodeRef} className="relative min-h-14 md:col-span-6">
{isOver ? (
<motion.div
layoutId="form-builder-drop-line"
className="absolute inset-x-2 top-1 z-10 h-0.5 rounded-full bg-primary"
/>
) : null}
<div
className={cn(
"flex h-full min-h-14 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground transition-colors",
isOver ? "border-primary bg-muted/40" : "border-transparent"
)}
>
{isOver ? "Drop to add a new row" : null}
</div>
</div>
)
}
function FormCanvas({
definition,
selectedFieldId,
onSelectField,
onRemoveField,
}: {
definition: FormDefinition
selectedFieldId: string | null
onSelectField: (id: string) => void
onRemoveField: (id: string) => void
}) {
const { setNodeRef, isOver } = useDroppable({ id: "form-canvas" })
const fields = orderedFields(definition.fields)
const rows = fieldRows(fields)
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-sm">Canvas</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Drag fields here, reorder them, then configure each field.
</p>
</div>
<Badge variant="secondary">{fields.length} fields</Badge>
</div>
</CardHeader>
<CardContent>
<div
ref={setNodeRef}
className={cn(
"min-h-96 rounded-md border border-dashed p-3 transition-colors",
isOver ? "border-primary bg-muted/40" : "bg-background"
)}
>
{fields.length === 0 ? (
<div className="flex min-h-80 items-center justify-center rounded-md">
<div className="text-center">
<LayoutPanelTopIcon className="mx-auto size-8 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Start with a field</p>
<p className="mt-1 text-xs text-muted-foreground">
Drag an input, text block, or page break from the palette.
</p>
</div>
</div>
) : (
<div className="grid gap-2 md:grid-cols-6">
{rows.map((row) =>
row.map((field) => {
const isPageBreak = field.type === "page-break"
const span =
isPageBreak || row.length === 1
? "md:col-span-6"
: row.length === 2
? "md:col-span-3"
: "md:col-span-2"
return (
<div key={field.id} className={span}>
<CanvasField
field={field}
rowSize={row.length}
selected={field.id === selectedFieldId}
onSelect={() => onSelectField(field.id)}
onDelete={() => onRemoveField(field.id)}
/>
</div>
)
})
)}
<CanvasEndDropZone />
</div>
)}
</div>
</CardContent>
</Card>
)
}
function FieldInspector({
definition,
selectedField,
onUpdateField,
onRemoveField,
}: {
definition: FormDefinition
selectedField: FormField | null
onUpdateField: (id: string, next: Partial<FormField>) => void
onRemoveField: (id: string) => void
}) {
const previousFields = React.useMemo(() => {
if (!selectedField) return []
const fields = orderedFields(definition.fields)
const index = fields.findIndex((field) => field.id === selectedField.id)
return fields
.slice(0, Math.max(0, index))
.filter((field) => field.type !== "page-break" && field.type !== "static-text")
}, [definition.fields, selectedField])
const selectedRule =
selectedField && selectedField.type !== "page-break"
? definition.visibilityRules?.find(
(rule) => rule.targetField === selectedField.key
)
: undefined
function updateVisibilityRule(next: FormVisibilityRule | null) {
if (!selectedField) return
const withoutCurrent = (definition.visibilityRules ?? []).filter(
(rule) => rule.targetField !== selectedField.key
)
const visibilityRules = next ? [...withoutCurrent, next] : withoutCurrent
onUpdateField(selectedField.id, { visibilityRules } as Partial<FormField>)
}
if (!selectedField) {
return (
<Card>
<CardContent className="flex min-h-80 items-center justify-center p-6 text-center">
<div>
<PanelRightIcon className="mx-auto size-8 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Select a field</p>
<p className="mt-1 text-xs text-muted-foreground">
Field configuration appears here.
</p>
</div>
</CardContent>
</Card>
)
}
const usesOptions =
selectedField.type === "select" ||
selectedField.type === "multi-select" ||
selectedField.type === "checkbox-group"
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Field settings</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
{selectedField.type === "page-break" ? (
<div className="grid gap-2">
<Label>Step label</Label>
<Input
value={selectedField.stepLabel ?? ""}
placeholder="Review"
onChange={(event) =>
onUpdateField(selectedField.id, {
stepLabel: event.target.value,
})
}
/>
</div>
) : (
<>
<div className="grid gap-2">
<Label>Label</Label>
<Input
value={selectedField.label}
onChange={(event) =>
onUpdateField(selectedField.id, {
label: event.target.value,
key: toFieldKey(event.target.value) || selectedField.key,
})
}
/>
</div>
<div className="grid gap-2">
<Label>Key</Label>
<Input
value={selectedField.key}
onChange={(event) =>
onUpdateField(selectedField.id, {
key: toFieldKey(event.target.value),
})
}
/>
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Select
value={selectedField.type}
onValueChange={(value: FormFieldType) =>
onUpdateField(selectedField.id, { type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((item) => (
<SelectItem key={item.type} value={item.type}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={Boolean(selectedField.required)}
disabled={selectedField.type === "static-text"}
onCheckedChange={(checked) =>
onUpdateField(selectedField.id, {
required: Boolean(checked),
})
}
/>
<Label>Required</Label>
</div>
{selectedField.type !== "static-text" ? (
<div className="grid gap-2">
<Label>Placeholder</Label>
<Input
value={selectedField.placeholder ?? ""}
onChange={(event) =>
onUpdateField(selectedField.id, {
placeholder: event.target.value,
})
}
/>
</div>
) : null}
<div className="grid gap-2">
<Label>
{selectedField.type === "static-text"
? "Text content"
: "Help text"}
</Label>
{selectedField.type === "static-text" ? (
<Textarea
rows={4}
value={String(selectedField.defaultValue ?? "")}
onChange={(event) =>
onUpdateField(selectedField.id, {
defaultValue: event.target.value,
required: false,
})
}
/>
) : (
<Input
value={selectedField.helpText ?? ""}
onChange={(event) =>
onUpdateField(selectedField.id, {
helpText: event.target.value,
})
}
/>
)}
</div>
</>
)}
{usesOptions ? (
<div className="grid gap-2">
<Label>Options</Label>
<Textarea
rows={4}
value={(selectedField.options ?? []).join("\n")}
onChange={(event) =>
onUpdateField(selectedField.id, {
options: event.target.value
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
})
}
/>
</div>
) : null}
{selectedField.type !== "page-break" ? (
<div className="grid gap-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm">Visible if</Label>
<Button
type="button"
size="sm"
variant="ghost"
disabled={!selectedRule}
onClick={() => updateVisibilityRule(null)}
>
Clear
</Button>
</div>
{previousFields.length === 0 ? (
<p className="text-xs text-muted-foreground">
Visibility rules can reference previous fields.
</p>
) : (
<>
<Select
value={selectedRule?.dependsOnField ?? ""}
onValueChange={(dependsOnField) => {
updateVisibilityRule({
targetField: selectedField.key,
dependsOnField,
operator: selectedRule?.operator ?? "equals",
value: selectedRule?.value ?? "",
})
}}
>
<SelectTrigger>
<SelectValue placeholder="Previous field" />
</SelectTrigger>
<SelectContent>
{previousFields.map((field) => (
<SelectItem key={field.key} value={field.key}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedRule?.operator ?? "equals"}
disabled={!selectedRule}
onValueChange={(operator: FormVisibilityOperator) => {
if (!selectedRule) return
updateVisibilityRule({ ...selectedRule, operator })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{visibilityOperators.map((operator) => (
<SelectItem key={operator.value} value={operator.value}>
{operator.label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedRule?.operator !== "is-empty" &&
selectedRule?.operator !== "is-not-empty" ? (
<Input
value={String(selectedRule?.value ?? "")}
placeholder="Comparison value"
disabled={!selectedRule}
onChange={(event) => {
if (!selectedRule) return
updateVisibilityRule({
...selectedRule,
value: event.target.value,
})
}}
/>
) : null}
</>
)}
</div>
) : null}
<Button
type="button"
variant="ghost"
className="w-fit px-2 text-muted-foreground hover:text-destructive"
onClick={() => onRemoveField(selectedField.id)}
>
<Trash2Icon className="size-4" />
Remove field
</Button>
</CardContent>
</Card>
)
}
function FormBuilderPropertiesSheet({
open,
sheetRef,
definition,
selectedField,
onUpdateField,
onRemoveField,
onClose,
}: {
open: boolean
sheetRef: React.RefObject<HTMLDivElement | null>
definition: FormDefinition
selectedField: FormField | null
onUpdateField: (id: string, next: Partial<FormField>) => void
onRemoveField: (id: string) => void
onClose: () => void
}) {
const reduceMotion = useReducedMotion()
return (
<AnimatePresence>
{open ? (
<motion.aside
ref={sheetRef}
className="absolute inset-y-0 right-0 z-30 flex w-[min(24rem,calc(100%-1.5rem))] flex-col border-l bg-popover text-popover-foreground shadow-xl"
initial={{ opacity: 0, x: reduceMotion ? 0 : 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: reduceMotion ? 0 : 24 }}
transition={{ duration: reduceMotion ? 0 : 0.18, ease: "easeOut" }}
>
<div className="flex flex-col gap-1.5 border-b p-6 pr-14">
<h2 className="font-heading text-sm font-medium text-foreground">
Field properties
</h2>
<p className="text-xs/relaxed text-muted-foreground">
Configure the selected field and its visibility rules.
</p>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="absolute right-4 top-4"
onClick={onClose}
>
<PanelRightIcon className="size-4" />
<span className="sr-only">Close properties</span>
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
<FieldInspector
definition={definition}
selectedField={selectedField}
onUpdateField={onUpdateField}
onRemoveField={onRemoveField}
/>
</div>
</ScrollArea>
</motion.aside>
) : null}
</AnimatePresence>
)
}
export function FormBuilder({
definition,
onDefinitionChange,
className,
}: {
definition: FormDefinition
onDefinitionChange: (definition: FormDefinition) => void
className?: string
}) {
const reduceMotion = useReducedMotion()
const isDesktopLayout = useMediaQuery("(min-width: 1024px)")
const [selectedFieldId, setSelectedFieldId] = React.useState<string | null>(null)
const [activeDragId, setActiveDragId] = React.useState<string | null>(null)
const [mobileTab, setMobileTab] = React.useState("canvas")
const sheetRef = React.useRef<HTMLDivElement | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
)
const selectedField =
definition.fields.find((field) => field.id === selectedFieldId) ?? null
React.useEffect(() => {
if (!selectedFieldId) return
function handlePointerDown(event: PointerEvent) {
const target = event.target
if (!(target instanceof Node)) return
if (sheetRef.current?.contains(target)) return
if (
target instanceof Element &&
target.closest("[data-form-builder-node]")
) {
return
}
setSelectedFieldId(null)
}
document.addEventListener("pointerdown", handlePointerDown, true)
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true)
}
}, [selectedFieldId])
function setDefinition(next: FormDefinition) {
onDefinitionChange(normalizeFormDefinition(next))
}
function updateField(id: string, next: Partial<FormField>) {
if ("visibilityRules" in next) {
onDefinitionChange({
...definition,
visibilityRules: next.visibilityRules as FormVisibilityRule[],
})
return
}
setDefinition({
...definition,
fields: definition.fields.map((field) =>
field.id === id ? { ...field, ...next } : field
),
})
}
function removeField(id: string) {
const removed = definition.fields.find((field) => field.id === id)
setDefinition({
...definition,
fields: definition.fields.filter((field) => field.id !== id),
visibilityRules: (definition.visibilityRules ?? []).filter(
(rule) =>
rule.targetField !== removed?.key && rule.dependsOnField !== removed?.key
),
})
setSelectedFieldId((current) => (current === id ? null : current))
}
function handleDragStart(event: DragStartEvent) {
setActiveDragId(String(event.active.id))
}
function handleDragEnd(event: DragEndEvent) {
const activeId = String(event.active.id)
const overId = String(event.over?.id ?? "")
setActiveDragId(null)
if (!overId) return
const fields = orderedFields(definition.fields)
let nextField: FormField | null = null
let nextFields = fields
if (activeId.startsWith("palette:")) {
const type = activeId.replace("palette:", "") as FormFieldType
nextField = createFormField(type, definition)
setSelectedFieldId(nextField.id)
} else if (activeId.startsWith("field:")) {
const id = activeId.replace("field:", "")
nextField = fields.find((field) => field.id === id) ?? null
nextFields = fields.filter((field) => field.id !== id)
}
if (!nextField) return
let insertIndex = nextFields.length
if (overId.startsWith("field:")) {
const [, overFieldId, position = "before"] = overId.split(":")
const foundIndex = nextFields.findIndex((field) => field.id === overFieldId)
if (foundIndex >= 0) insertIndex = position === "after" ? foundIndex + 1 : foundIndex
} else if (overId === "form-canvas" || overId === "form-canvas-end") {
insertIndex = nextFields.length
}
nextFields.splice(insertIndex, 0, nextField)
setDefinition({ ...definition, fields: applyFieldLayout(nextFields) })
}
const activePaletteType = activeDragId?.startsWith("palette:")
? (activeDragId.replace("palette:", "") as FormFieldType)
: null
const activePaletteField = activePaletteType
? fieldTypes.find((field) => field.type === activePaletteType)
: null
const activeCanvasField = activeDragId?.startsWith("field:")
? definition.fields.find(
(field) => field.id === activeDragId.replace("field:", "")
)
: null
const builderGrid = (
<DndContext
sensors={sensors}
collisionDetection={canvasAwareCollisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{isDesktopLayout ? (
<div className="grid gap-4 lg:grid-cols-[15rem_minmax(0,1fr)]">
<FieldPalette />
<FormCanvas
definition={definition}
selectedFieldId={selectedFieldId}
onSelectField={setSelectedFieldId}
onRemoveField={removeField}
/>
</div>
) : (
<Tabs
value={mobileTab}
onValueChange={setMobileTab}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="palette">Palette</TabsTrigger>
<TabsTrigger value="canvas">Canvas</TabsTrigger>
</TabsList>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={mobileTab}
initial={{ opacity: 0, x: reduceMotion ? 0 : 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: reduceMotion ? 0 : -12 }}
transition={{ duration: reduceMotion ? 0 : 0.18 }}
className="mt-3"
>
{mobileTab === "palette" ? (
<TabsContent value="palette" forceMount>
<FieldPalette />
</TabsContent>
) : (
<TabsContent value="canvas" forceMount>
<FormCanvas
definition={definition}
selectedFieldId={selectedFieldId}
onSelectField={(id) => {
setSelectedFieldId(id)
}}
onRemoveField={removeField}
/>
</TabsContent>
)}
</motion.div>
</AnimatePresence>
</Tabs>
)}
{typeof document !== "undefined"
? createPortal(
<DragOverlay modifiers={[snapOverlayToCursor]}>
{activePaletteField ? (
<PaletteFieldPreview {...activePaletteField} />
) : activeCanvasField ? (
<CanvasFieldPreview field={activeCanvasField} />
) : null}
</DragOverlay>,
document.body
)
: null}
</DndContext>
)
return (
<div className={cn("relative overflow-hidden rounded-md border bg-card p-3", className)}>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">
{definition.title || "Form builder"}
</h2>
{definition.description ? (
<p className="mt-1 text-sm text-muted-foreground">
{definition.description}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{buildFormPages(definition).length} pages</Badge>
<FormPreviewDialog definition={definition} />
</div>
</div>
{builderGrid}
<FormBuilderPropertiesSheet
open={Boolean(selectedField)}
sheetRef={sheetRef}
definition={definition}
selectedField={selectedField}
onUpdateField={updateField}
onRemoveField={removeField}
onClose={() => setSelectedFieldId(null)}
/>
</div>
)
}
function FieldError({ error }: { error?: string }) {
if (!error) return null
return <p className="text-xs text-destructive">{error}</p>
}
function FieldHelp({ children }: { children?: React.ReactNode }) {
if (!children) return null
return <p className="text-xs text-muted-foreground">{children}</p>
}
function parseDate(value: unknown) {
if (typeof value !== "string" || !value) return undefined
const [year, month, day] = value.split("-").map(Number)
if (!year || !month || !day) return undefined
return new Date(year, month - 1, day, 12)
}
function MultiSelectCombobox({
field,
value,
onChange,
}: {
field: FormField
value: unknown
onChange: (value: string[]) => void
}) {
const anchor = useComboboxAnchor()
const selected = Array.isArray(value) ? value.map(String) : []
const options = field.options ?? []
return (
<Combobox
multiple
autoHighlight
items={options}
value={selected}
onValueChange={(nextValue) => {
onChange(Array.isArray(nextValue) ? nextValue.map(String) : [])
}}
>
<ComboboxChips ref={anchor} className="w-full">
<ComboboxValue>
{(values) => (
<React.Fragment>
{(values as string[]).map((item) => (
<ComboboxChip key={item}>{item}</ComboboxChip>
))}
<ComboboxChipsInput placeholder={field.placeholder ?? "Select options"} />
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No options found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={String(item)} value={String(item)}>
{String(item)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}
function renderFormField({
field,
value,
error,
onChange,
}: {
field: FormField
value: unknown
error?: string
onChange: (value: unknown) => void
}) {
const fieldId = `form-field-${field.id}`
const label = (
<Label htmlFor={fieldId}>
{field.label}
{field.required ? <span className="ml-1 text-destructive">*</span> : null}
</Label>
)
if (field.type === "static-text") {
return (
<div className="rounded-md border bg-muted/20 p-3">
<div className="font-medium">{field.label}</div>
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{String(field.defaultValue ?? field.helpText ?? "")}
</p>
</div>
)
}
if (field.type === "textarea") {
return (
<div className="grid gap-2">
{label}
<Textarea
id={fieldId}
placeholder={field.placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.target.value)}
/>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
if (field.type === "date") {
return (
<div className="grid gap-2">
{label}
<DatePickerField
buttonId={fieldId}
className="w-full justify-between text-left font-normal data-[empty=true]:text-muted-foreground"
date={parseDate(value)}
placeholder={field.placeholder ?? "Pick a date"}
onSelect={() => undefined}
onSelectDateString={(dateString: string | undefined) =>
onChange(dateString ?? "")
}
closeOnSelect
/>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
if (field.type === "datetime") {
return (
<div className="grid gap-2">
{label}
<Input
id={fieldId}
type="datetime-local"
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.target.value)}
/>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
if (field.type === "select") {
return (
<div className="grid gap-2">
{label}
<Select
value={typeof value === "string" ? value : ""}
onValueChange={onChange}
>
<SelectTrigger id={fieldId}>
<SelectValue placeholder={field.placeholder ?? "Select option"} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
if (field.type === "multi-select") {
return (
<div className="grid gap-2">
{label}
<MultiSelectCombobox
field={field}
value={value}
onChange={onChange}
/>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
if (field.type === "checkbox-group") {
const selected = Array.isArray(value) ? value.map(String) : []
return (
<div className="grid gap-2">
{label}
<div className="grid gap-2 rounded-md border p-3">
{(field.options ?? []).map((option) => (
<label key={option} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selected.includes(option)}
onCheckedChange={(checked) => {
const set = new Set(selected)
if (checked) set.add(option)
else set.delete(option)
onChange(Array.from(set))
}}
/>
{option}
</label>
))}
</div>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
if (field.type === "checkbox") {
return (
<div className="grid gap-2">
<div className="flex items-center gap-2">
<Checkbox
id={fieldId}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(Boolean(checked))}
/>
{label}
</div>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
if (field.type === "number") {
return (
<div className="grid gap-2">
{label}
<Input
id={fieldId}
type="number"
placeholder={field.placeholder}
value={typeof value === "number" || typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.target.value)}
/>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
return (
<div className="grid gap-2">
{label}
<Input
id={fieldId}
type={field.type === "email" ? "email" : "text"}
placeholder={field.placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.target.value)}
/>
<FieldHelp>{field.helpText}</FieldHelp>
<FieldError error={error} />
</div>
)
}
export function FormRenderer({
definition,
response,
renderDefinition,
renderMode = "submitted-version",
actor,
mode = "create",
value,
onValueChange,
onSubmit,
submitLabel = "Submit",
footerContainer,
footerClassName,
formId,
className,
}: {
definition: FormDefinition
response?: FormResponseRecord
renderDefinition?: FormDefinition
renderMode?: FormResponseRenderMode
actor?: FormActor
mode?: "create" | "edit"
value?: FormResponses
onValueChange?: (responses: FormResponses) => void
onSubmit?: (response: FormResponseRecord) => void
submitLabel?: string
footerContainer?: HTMLElement | null
footerClassName?: string
formId?: string
className?: string
}) {
const reduceMotion = useReducedMotion()
const resolvedDefinition = React.useMemo(
() =>
response
? resolveFormDefinitionForResponse(response, renderDefinition ?? definition, renderMode)
: renderDefinition ?? definition,
[definition, renderDefinition, renderMode, response]
)
const normalizedDefinition = React.useMemo(
() => normalizeFormDefinition(resolvedDefinition),
[resolvedDefinition]
)
const pages = React.useMemo(
() => buildFormPages(normalizedDefinition),
[normalizedDefinition]
)
const initialValue = React.useMemo(
() => value ?? (response ? getFormResponseValues(response) : initialResponses(normalizedDefinition)),
[normalizedDefinition, response, value]
)
const [responses, setResponses] = React.useState<FormResponses>(() =>
initialValue
)
const [errors, setErrors] = React.useState<Record<string, string>>({})
const [pageIndex, setPageIndex] = React.useState(0)
const shouldNotifyValueChange = React.useRef(false)
const currentPage = pages[Math.min(pageIndex, Math.max(0, pages.length - 1))]
const isFinalPage = pageIndex >= pages.length - 1
const visibility = getFieldVisibility(normalizedDefinition, responses)
const isRenderingLatest =
Boolean(response && renderMode === "latest-version" && renderDefinition) &&
formVersionFor(renderDefinition ?? definition) !== response?.formVersion
React.useEffect(() => {
setResponses(initialValue)
shouldNotifyValueChange.current = false
}, [initialValue])
React.useEffect(() => {
if (!shouldNotifyValueChange.current) return
shouldNotifyValueChange.current = false
onValueChange?.(responses)
}, [onValueChange, responses])
function updateResponse(key: string, nextValue: unknown) {
shouldNotifyValueChange.current = true
setResponses((current) => {
const next = { ...current, [key]: nextValue }
return next
})
}
function validateCurrentPage() {
const fieldKeys = currentPage?.fields.map((field) => field.key) ?? []
const nextErrors = validateFormResponses(
normalizedDefinition,
responses,
fieldKeys
)
setErrors(nextErrors)
return Object.keys(nextErrors).length === 0
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!currentPage) return
if (!isFinalPage) {
if (validateCurrentPage()) setPageIndex((current) => current + 1)
return
}
const nextErrors = validateFormResponses(normalizedDefinition, responses)
setErrors(nextErrors)
if (Object.keys(nextErrors).length === 0) {
onSubmit?.(
mode === "edit" && response
? updateFormResponseRecord(response, responses, { actor })
: createFormResponseRecord(normalizedDefinition, responses, { actor })
)
}
}
const footer = (
<div
className={cn(
"flex justify-end gap-2 border-t p-4",
footerContainer && "border-t-0 p-0",
footerClassName
)}
>
{pages.length > 1 ? (
<Button
type="button"
variant="outline"
disabled={pageIndex === 0}
onClick={() => setPageIndex((current) => Math.max(0, current - 1))}
>
Back
</Button>
) : null}
<Button type="submit" form={formId}>
{pages.length > 1 && !isFinalPage ? "Next" : submitLabel}
</Button>
</div>
)
return (
<form
id={formId}
onSubmit={handleSubmit}
className={cn("rounded-md border bg-card", className)}
>
<div className="border-b p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">
{normalizedDefinition.title ?? "Generated form"}
</h2>
{normalizedDefinition.description ? (
<p className="mt-1 text-sm text-muted-foreground">
{normalizedDefinition.description}
</p>
) : null}
</div>
{pages.length > 1 ? (
<Badge variant="secondary">
Step {pageIndex + 1} of {pages.length}
</Badge>
) : null}
</div>
{isRenderingLatest ? (
<div className="mt-4 rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
Rendering this saved response against the latest form version. Stored response data remains unchanged.
</div>
) : null}
{pages.length > 1 ? (
<Stepper
steps={pages.map((page) => ({
id: page.id,
title: page.label,
description: `${page.fields.length} fields`,
}))}
currentStep={pageIndex}
className="mt-4"
/>
) : null}
</div>
<div className="overflow-hidden p-4">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentPage?.id ?? "empty"}
initial={{ opacity: 0, x: reduceMotion ? 0 : 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: reduceMotion ? 0 : -12 }}
transition={{ duration: reduceMotion ? 0 : 0.18 }}
className="grid gap-4 md:grid-cols-2"
>
{(currentPage?.fields ?? [])
.filter((field) => visibility[field.key])
.map((field) => (
<div
key={field.id}
className={cn(
(field.type === "textarea" ||
field.type === "static-text" ||
field.type === "checkbox-group" ||
field.type === "multi-select") &&
"md:col-span-2"
)}
>
{renderFormField({
field,
value: responses[field.key],
error: errors[field.key],
onChange: (nextValue) => updateResponse(field.key, nextValue),
})}
</div>
))}
</motion.div>
</AnimatePresence>
</div>
{footerContainer ? createPortal(footer, footerContainer) : footer}
</form>
)
}
export function FormPreviewDialog({
definition,
}: {
definition: FormDefinition
}) {
const [open, setOpen] = React.useState(false)
const isSmallScreen = useIsSmallScreen()
const formId = React.useId()
const [drawerFooterActions, setDrawerFooterActions] =
React.useState<HTMLDivElement | null>(null)
const trigger = (
<Button type="button" variant="outline" size="sm">
<EyeIcon className="size-4" />
Preview
</Button>
)
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setOpen(true)}
>
<EyeIcon className="size-4" />
Preview
</Button>
<DrawerContent className="flex h-[92svh] max-h-[92svh] flex-col overflow-hidden">
<DrawerHeader className="shrink-0 text-left">
<DrawerTitle>Form preview</DrawerTitle>
<DrawerDescription>
Test the generated form before saving.
</DrawerDescription>
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="px-4 pb-4">
<FormRenderer
definition={definition}
formId={formId}
footerContainer={drawerFooterActions}
footerClassName="grid grid-cols-2 gap-2 [&>button:only-child]:col-span-2"
/>
</div>
</ScrollArea>
<DrawerFooter className="shrink-0 border-t">
<div ref={setDrawerFooterActions} />
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex h-[min(90svh,46rem)] overflow-hidden sm:max-w-4xl sm:flex-col">
<DialogHeader className="shrink-0 pr-8">
<DialogTitle>Form preview</DialogTitle>
<DialogDescription>
Test the generated form before saving the JSON definition.
</DialogDescription>
</DialogHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="pr-3 pb-1">
<FormRenderer definition={definition} />
</div>
</ScrollArea>
<DialogFooter className="shrink-0 border-t pt-3">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export const exampleFormDefinition: FormDefinition = normalizeFormDefinition({
id: "project-intake",
version: 1,
title: "Project intake",
description: "Capture enough context to triage a new request.",
metadata: {
author: {
id: "user-liam",
name: "Liam Corrigan",
email: "liam@corr.sh",
},
createdAt: "2026-05-01T09:00:00.000Z",
modifiedAt: "2026-05-10T14:30:00.000Z",
modifiedBy: {
id: "user-liam",
name: "Liam Corrigan",
email: "liam@corr.sh",
},
publishedAt: "2026-05-12T10:00:00.000Z",
status: "published",
},
fields: [
{
id: "field-name",
key: "contact_name",
label: "Contact name",
type: "text",
required: true,
placeholder: "Liam Corrigan",
},
{
id: "field-email",
key: "contact_email",
label: "Email",
type: "email",
required: true,
placeholder: "liam@example.com",
},
{
id: "field-budget",
key: "budget",
label: "Budget range",
type: "select",
options: ["Under 5k", "5k-15k", "15k+"],
placeholder: "Select budget",
},
{
id: "field-type",
key: "project_type",
label: "Project type",
type: "select",
required: true,
options: ["Component", "Block", "Integration", "Consultancy"],
placeholder: "Select project type",
},
{
id: "field-page-break",
key: "page_break_1",
label: "Page break",
type: "page-break",
stepLabel: "Scope",
},
{
id: "field-summary",
key: "summary",
label: "Project summary",
type: "textarea",
required: true,
placeholder: "What are you trying to build?",
helpText: "Include goals, constraints, and any current tooling.",
},
{
id: "field-features",
key: "features",
label: "Needed features",
type: "checkbox-group",
options: ["Design system", "Dashboard", "Forms", "Analytics"],
helpText: "Pick every feature that applies.",
},
],
visibilityRules: [
{
targetField: "budget",
dependsOnField: "project_type",
operator: "is-not-empty",
},
],
})
export function FormBuilderDemo() {
const [definition, setDefinition] = React.useState<FormDefinition>(
exampleFormDefinition
)
return <FormBuilder definition={definition} onDefinitionChange={setDefinition} />
}