Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions registry/form-builder/canvas-drop-zone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client";

import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import { CANVAS_DROP_ZONE_ID } from "./lib/dnd-utils";

interface CanvasDropZoneProps {
children: React.ReactNode;
isEmpty: boolean;
}

export function CanvasDropZone({ children, isEmpty }: CanvasDropZoneProps) {
const { setNodeRef, isOver } = useDroppable({
id: CANVAS_DROP_ZONE_ID,
});

return (
<div
ref={setNodeRef}
className={cn(
"min-h-[200px] rounded-lg border border-transparent transition-all",
isOver && "bg-primary/5 border-primary/40 border-dashed",
isEmpty && "flex items-center justify-center"
)}
>
{children}
</div>
);
}
40 changes: 40 additions & 0 deletions registry/form-builder/draggable-toolbox-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { useDraggable } from "@dnd-kit/core";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { createToolboxId } from "./lib/dnd-utils";
import type { FieldType, FieldTypeConfig } from "./lib/form-config";

interface DraggableToolboxItemProps {
fieldType: FieldTypeConfig;
onAddField: (type: FieldType) => void;
}

export function DraggableToolboxItem({
fieldType,
onAddField,
}: DraggableToolboxItemProps) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: createToolboxId(fieldType.type),
});

return (
<Button
ref={setNodeRef}
variant="outline"
className={cn(
"flex flex-col items-center justify-center h-16 w-full gap-1.5 whitespace-normal px-2 py-3 text-xs hover:border-primary hover:text-primary transition-all [&>svg]:shrink-0",
isDragging && "opacity-50"
)}
onClick={() => onAddField(fieldType.type)}
{...listeners}
{...attributes}
>
<fieldType.icon className="h-5 w-5" />
<span className="text-[10px] font-medium leading-tight text-center">
{fieldType.label}
</span>
</Button>
);
}
16 changes: 5 additions & 11 deletions registry/form-builder/field-toolbox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { Button } from "@/components/ui/button";
import { FIELD_TYPES, CATEGORIES, FieldType } from "./lib/form-config";
import { DraggableToolboxItem } from "./draggable-toolbox-item";

interface FieldToolboxProps {
onAddField: (type: FieldType) => void;
Expand All @@ -19,17 +19,11 @@ export function FieldToolbox({ onAddField }: FieldToolboxProps) {
<div className="grid grid-cols-2 gap-2">
{FIELD_TYPES.filter((f) => f.category === category.id).map(
(item) => (
<Button
<DraggableToolboxItem
key={item.type}
variant="outline"
className="flex flex-col items-center justify-center h-16 w-full gap-1.5 whitespace-normal px-2 py-3 text-xs hover:border-primary hover:text-primary transition-all [&>svg]:shrink-0"
onClick={() => onAddField(item.type)}
>
<item.icon className="h-5 w-5" />
<span className="text-[10px] font-medium leading-tight text-center">
{item.label}
</span>
</Button>
fieldType={item}
onAddField={onAddField}
/>
)
)}
</div>
Expand Down
253 changes: 151 additions & 102 deletions registry/form-builder/form-builder.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
"use client";

import { useState, useCallback } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
DragStartEvent,
DragEndEvent,
pointerWithin,
CollisionDetection,
rectIntersection,
} from "@dnd-kit/core";
import {
SortableContext,
Expand All @@ -26,6 +33,9 @@ import { FormRenderer } from "./form-renderer";
import { SortableField } from "./sortable-field";
import { FloatingControls } from "./floating-controls";
import { FieldToolbox } from "./field-toolbox";
import { CanvasDropZone } from "./canvas-drop-zone";
import { ToolboxDragOverlay } from "./toolbox-drag-overlay";
import { isToolboxItem, getFieldTypeFromToolboxId } from "./lib/dnd-utils";

export function FormBuilder() {
const {
Expand All @@ -44,121 +54,160 @@ export function FormBuilder() {
handleImport,
} = useFormBuilder();

const [activeId, setActiveId] = useState<string | null>(null);

const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);

const collisionDetection: CollisionDetection = useCallback((args) => {
const { active } = args;

if (isToolboxItem(active.id as string)) {
const rectCollisions = rectIntersection(args);
if (rectCollisions.length > 0) {
return rectCollisions;
}
return pointerWithin(args);
}

return closestCenter(args);
}, []);

const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};

const handleDragEndWrapper = (event: DragEndEvent) => {
handleDragEnd(event);
setActiveId(null);
};

const handleDragCancel = () => {
setActiveId(null);
};

return (
<div className="relative h-full w-full">
<SidebarProvider
defaultOpen
className="h-full min-h-0! **:data-[slot=sidebar-container]:absolute! **:data-[slot=sidebar-container]:h-full! **:data-[slot=sidebar-container]:inset-y-0!"
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEndWrapper}
onDragCancel={handleDragCancel}
>
<div className="flex h-full w-full relative">
<FloatingControls
viewMode={viewMode}
onViewModeChange={setViewMode}
onImport={handleImport}
onExport={handleExport}
/>

<Sidebar side="left" collapsible="offcanvas">
{viewMode === "builder" && <FieldToolbox onAddField={addField} />}
</Sidebar>

<SidebarInset className="flex-1 overflow-y-auto bg-muted/20 p-4 md:p-8 w-full">
{viewMode === "builder" && (
<div className="mx-auto w-full max-w-4xl min-h-full">
{/* Form Header */}
<div className="mb-8">
<input
value={formConfig.title}
onChange={(e) =>
updateFormConfig({ title: e.target.value })
}
placeholder="Untitled Form"
className="w-full bg-transparent text-2xl md:text-3xl font-semibold placeholder:text-muted-foreground/50 focus:outline-none border-none p-0 mb-2"
/>
<input
value={formConfig.description || ""}
onChange={(e) =>
updateFormConfig({ description: e.target.value })
}
placeholder="Add a description..."
className="w-full bg-transparent text-muted-foreground placeholder:text-muted-foreground/50 focus:outline-none border-none p-0"
/>
<SidebarProvider
defaultOpen
className="h-full min-h-0! **:data-[slot=sidebar-container]:absolute! **:data-[slot=sidebar-container]:h-full! **:data-[slot=sidebar-container]:inset-y-0!"
>
<div className="flex h-full w-full relative">
<FloatingControls
viewMode={viewMode}
onViewModeChange={setViewMode}
onImport={handleImport}
onExport={handleExport}
/>

<Sidebar side="left" collapsible="offcanvas">
{viewMode === "builder" && <FieldToolbox onAddField={addField} />}
</Sidebar>

<SidebarInset className="flex-1 overflow-y-auto bg-muted/20 p-4 md:p-8 w-full">
{viewMode === "builder" && (
<div className="mx-auto w-full max-w-4xl min-h-full">
{/* Form Header */}
<div className="mb-8">
<input
value={formConfig.title}
onChange={(e) =>
updateFormConfig({ title: e.target.value })
}
placeholder="Untitled Form"
className="w-full bg-transparent text-2xl md:text-3xl font-semibold placeholder:text-muted-foreground/50 focus:outline-none border-none p-0 mb-2"
/>
<input
value={formConfig.description || ""}
onChange={(e) =>
updateFormConfig({ description: e.target.value })
}
placeholder="Add a description..."
className="w-full bg-transparent text-muted-foreground placeholder:text-muted-foreground/50 focus:outline-none border-none p-0"
/>
</div>

{/* Fields */}
<CanvasDropZone isEmpty={formConfig.fields.length === 0}>
{formConfig.fields.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 border border-dashed rounded-lg bg-muted/10">
<MousePointerClick className="h-10 w-10 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground font-medium mb-1">
No fields yet
</p>
<p className="text-sm text-muted-foreground/70">
Drag a field from the sidebar or click to add
</p>
</div>
) : (
<SortableContext
items={formConfig.fields.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4 pb-20">
{formConfig.fields.map((field) => (
<SortableField
key={field.id}
field={field}
isSelected={selectedFieldId === field.id}
onSelect={() => setSelectedFieldId(field.id)}
onDelete={() => deleteField(field.id)}
onDuplicate={() => duplicateField(field.id)}
onUpdate={updateField}
/>
))}
</div>
</SortableContext>
)}
</CanvasDropZone>
</div>
)}

{/* Fields */}
{formConfig.fields.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 border border-dashed rounded-lg bg-muted/10">
<MousePointerClick className="h-10 w-10 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground font-medium mb-1">
No fields yet
</p>
<p className="text-sm text-muted-foreground/70">
Click a field type in the sidebar to add it
</p>
{viewMode === "preview" && (
<div className="mx-auto w-full max-w-2xl">
<FormRenderer config={formConfig} />
</div>
)}

{viewMode === "json" && (
<div className="mx-auto w-full max-w-4xl space-y-6">
<div className="rounded-lg border bg-muted/30 p-6">
<pre className="text-sm overflow-x-auto leading-relaxed font-mono">
{JSON.stringify(formConfig, null, 2)}
</pre>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
<Button
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify(formConfig, null, 2)
);
toast.success("Copied to clipboard");
}}
variant="outline"
>
<SortableContext
items={formConfig.fields.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4 pb-20">
{formConfig.fields.map((field) => (
<SortableField
key={field.id}
field={field}
isSelected={selectedFieldId === field.id}
onSelect={() => setSelectedFieldId(field.id)}
onDelete={() => deleteField(field.id)}
onDuplicate={() => duplicateField(field.id)}
onUpdate={updateField}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
)}

{viewMode === "preview" && (
<div className="mx-auto w-full max-w-2xl">
<FormRenderer config={formConfig} />
</div>
)}

{viewMode === "json" && (
<div className="mx-auto w-full max-w-4xl space-y-6">
<div className="rounded-lg border bg-muted/30 p-6">
<pre className="text-sm overflow-x-auto leading-relaxed font-mono">
{JSON.stringify(formConfig, null, 2)}
</pre>
Copy to Clipboard
</Button>
</div>
<Button
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify(formConfig, null, 2)
);
toast.success("Copied to clipboard");
}}
variant="outline"
>
Copy to Clipboard
</Button>
</div>
)}
</SidebarInset>
</div>
</SidebarProvider>
)}
</SidebarInset>
</div>
</SidebarProvider>

{activeId && isToolboxItem(activeId) && (
<DragOverlay dropAnimation={null}>
<ToolboxDragOverlay fieldType={getFieldTypeFromToolboxId(activeId)} />
</DragOverlay>
)}
</DndContext>
</div>
);
}
Loading