diff --git a/registry/form-builder/canvas-drop-zone.tsx b/registry/form-builder/canvas-drop-zone.tsx new file mode 100644 index 0000000..195db60 --- /dev/null +++ b/registry/form-builder/canvas-drop-zone.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/registry/form-builder/draggable-toolbox-item.tsx b/registry/form-builder/draggable-toolbox-item.tsx new file mode 100644 index 0000000..a4a77c9 --- /dev/null +++ b/registry/form-builder/draggable-toolbox-item.tsx @@ -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 ( + + ); +} diff --git a/registry/form-builder/field-toolbox.tsx b/registry/form-builder/field-toolbox.tsx index 23a25af..7ef1172 100644 --- a/registry/form-builder/field-toolbox.tsx +++ b/registry/form-builder/field-toolbox.tsx @@ -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; @@ -19,17 +19,11 @@ export function FieldToolbox({ onAddField }: FieldToolboxProps) {
{FIELD_TYPES.filter((f) => f.category === category.id).map( (item) => ( - + fieldType={item} + onAddField={onAddField} + /> ) )}
diff --git a/registry/form-builder/form-builder.tsx b/registry/form-builder/form-builder.tsx index 624fcbb..db2a74a 100644 --- a/registry/form-builder/form-builder.tsx +++ b/registry/form-builder/form-builder.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useCallback } from "react"; import { DndContext, closestCenter, @@ -7,6 +8,12 @@ import { PointerSensor, useSensor, useSensors, + DragOverlay, + DragStartEvent, + DragEndEvent, + pointerWithin, + CollisionDetection, + rectIntersection, } from "@dnd-kit/core"; import { SortableContext, @@ -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 { @@ -44,121 +54,160 @@ export function FormBuilder() { handleImport, } = useFormBuilder(); + const [activeId, setActiveId] = useState(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 (
- -
- - - - {viewMode === "builder" && } - - - - {viewMode === "builder" && ( -
- {/* Form Header */} -
- - 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" - /> - - 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" - /> + +
+ + + + {viewMode === "builder" && } + + + + {viewMode === "builder" && ( +
+ {/* Form Header */} +
+ + 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" + /> + + 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" + /> +
+ + {/* Fields */} + + {formConfig.fields.length === 0 ? ( +
+ +

+ No fields yet +

+

+ Drag a field from the sidebar or click to add +

+
+ ) : ( + f.id)} + strategy={verticalListSortingStrategy} + > +
+ {formConfig.fields.map((field) => ( + setSelectedFieldId(field.id)} + onDelete={() => deleteField(field.id)} + onDuplicate={() => duplicateField(field.id)} + onUpdate={updateField} + /> + ))} +
+
+ )} +
+ )} - {/* Fields */} - {formConfig.fields.length === 0 ? ( -
- -

- No fields yet -

-

- Click a field type in the sidebar to add it -

+ {viewMode === "preview" && ( +
+ +
+ )} + + {viewMode === "json" && ( +
+
+
+                      {JSON.stringify(formConfig, null, 2)}
+                    
- ) : ( - { + navigator.clipboard.writeText( + JSON.stringify(formConfig, null, 2) + ); + toast.success("Copied to clipboard"); + }} + variant="outline" > - f.id)} - strategy={verticalListSortingStrategy} - > -
- {formConfig.fields.map((field) => ( - setSelectedFieldId(field.id)} - onDelete={() => deleteField(field.id)} - onDuplicate={() => duplicateField(field.id)} - onUpdate={updateField} - /> - ))} -
-
-
- )} -
- )} - - {viewMode === "preview" && ( -
- -
- )} - - {viewMode === "json" && ( -
-
-
-                    {JSON.stringify(formConfig, null, 2)}
-                  
+ Copy to Clipboard +
- -
- )} - -
- + )} +
+
+
+ + {activeId && isToolboxItem(activeId) && ( + + + + )} +
); } diff --git a/registry/form-builder/lib/dnd-utils.ts b/registry/form-builder/lib/dnd-utils.ts new file mode 100644 index 0000000..552c6d5 --- /dev/null +++ b/registry/form-builder/lib/dnd-utils.ts @@ -0,0 +1,16 @@ +import type { FieldType } from "./form-config"; + +export const TOOLBOX_PREFIX = "toolbox-"; +export const CANVAS_DROP_ZONE_ID = "canvas-drop-zone"; + +export function isToolboxItem(id: string | number): boolean { + return typeof id === "string" && id.startsWith(TOOLBOX_PREFIX); +} + +export function getFieldTypeFromToolboxId(id: string): FieldType { + return id.replace(TOOLBOX_PREFIX, "") as FieldType; +} + +export function createToolboxId(fieldType: FieldType): string { + return `${TOOLBOX_PREFIX}${fieldType}`; +} diff --git a/registry/form-builder/toolbox-drag-overlay.tsx b/registry/form-builder/toolbox-drag-overlay.tsx new file mode 100644 index 0000000..71a503c --- /dev/null +++ b/registry/form-builder/toolbox-drag-overlay.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { FIELD_TYPES, FieldType } from "./lib/form-config"; + +interface ToolboxDragOverlayProps { + fieldType: FieldType; +} + +export function ToolboxDragOverlay({ fieldType }: ToolboxDragOverlayProps) { + const fieldConfig = FIELD_TYPES.find((f) => f.type === fieldType)!; + const Icon = fieldConfig.icon; + + return ( +
+ + {fieldConfig.label} +
+ ); +} diff --git a/registry/form-builder/use-form-builder.ts b/registry/form-builder/use-form-builder.ts index aee54b9..1f6459e 100644 --- a/registry/form-builder/use-form-builder.ts +++ b/registry/form-builder/use-form-builder.ts @@ -7,6 +7,11 @@ import { toast } from "sonner"; import type { FormConfig, FieldType, FieldConfig } from "./lib/form-config"; import { createDefaultField, createEmptyForm } from "./lib/form-config"; import { downloadFormConfig, parseFormConfig } from "./lib/form-utils"; +import { + isToolboxItem, + getFieldTypeFromToolboxId, + CANVAS_DROP_ZONE_ID, +} from "./lib/dnd-utils"; export type ViewMode = "builder" | "preview" | "json"; @@ -24,6 +29,19 @@ export function useFormBuilder() { }); }; + const addFieldAtIndex = (type: FieldType, index: number) => { + const newField = createDefaultField(type, formConfig.fields.length); + setFormConfig((prev) => { + const fields = [...prev.fields]; + fields.splice(index, 0, newField); + return { ...prev, fields }; + }); + setSelectedFieldId(newField.id); + toast.success("Field added", { + description: `${type} field added to the form`, + }); + }; + const deleteField = (fieldId: string) => { setFormConfig((prev) => ({ ...prev, @@ -64,13 +82,41 @@ export function useFormBuilder() { const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (active.id === over?.id) return; - setFormConfig((prev) => { - const oldIndex = prev.fields.findIndex((f) => f.id === active.id); - const newIndex = prev.fields.findIndex((f) => f.id === over?.id); - return { ...prev, fields: arrayMove(prev.fields, oldIndex, newIndex) }; - }); + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + if (isToolboxItem(activeId)) { + const fieldType = getFieldTypeFromToolboxId(activeId); + + if (overId === CANVAS_DROP_ZONE_ID) { + addField(fieldType); + return; + } + + const overIndex = formConfig.fields.findIndex((f) => f.id === overId); + if (overIndex !== -1) { + addFieldAtIndex(fieldType, overIndex); + return; + } + + addField(fieldType); + return; + } + + if (activeId !== overId) { + const oldIndex = formConfig.fields.findIndex((f) => f.id === activeId); + const newIndex = formConfig.fields.findIndex((f) => f.id === overId); + + if (oldIndex !== -1 && newIndex !== -1) { + setFormConfig((prev) => ({ + ...prev, + fields: arrayMove(prev.fields, oldIndex, newIndex), + })); + } + } }; const handleExport = () => downloadFormConfig(formConfig);