buzzform
uiform-buildersource: ok · hit
FormBuilder
Visual drag-and-drop form builder primitives for BuzzForm v2.
npx shadcn add "https://form.buildnbuzz.com/r/form-builder.json"free
Interaction
drag
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@buildnbuzz/form-core@buildnbuzz/form-react@buildnbuzz/form-builder-core@buildnbuzz/form-builder-react@dnd-kit/core@dnd-kit/sortable@dnd-kit/utilitiesfumadocs-uizustand
Registry dependencies
@buzzform/allalertbadgebuttoncardcheckboxcollapsibledialogemptyfieldinputscroll-areaselectseparatorsheetsidebarsonnerspinnerswitchtabstextareatooltip
Files27 · source cached 10/8/2026, 2:57:18 PM
registry/shadcn/builder/canvas/canvas-toolbar.tsx
"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useBuilderStore, useUndoRedo } from "@buildnbuzz/form-builder-react";
import { IconPlaceholder } from "@/components/icon-placeholder";
/**
* Floating canvas toolbar at bottom-center of the canvas area.
* Provides undo/redo, mode toggle, viewport switching, and zoom controls.
*/
export function CanvasToolbar() {
const viewport = useBuilderStore((state) => state.viewport);
const setViewport = useBuilderStore((state) => state.setViewport);
const zoom = useBuilderStore((state) => state.zoom);
const setZoom = useBuilderStore((state) => state.setZoom);
const mode = useBuilderStore((state) => state.mode);
const setMode = useBuilderStore((state) => state.setMode);
const { undo, redo, canUndo, canRedo } = useUndoRedo();
const isPreview = mode === "preview";
return (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1 p-1.5 rounded-full border border-border/50 bg-card/80 backdrop-blur-xl shadow-xl shadow-black/5 supports-backdrop-filter:bg-card/40">
<TooltipProvider delay={0}>
{/* Undo/Redo */}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
onClick={() => undo()}
disabled={!canUndo}
className={cn(
"h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/50",
!canUndo && "opacity-50 cursor-not-allowed",
)}
>
<IconPlaceholder
lucide="Undo"
hugeicons="UndoIcon"
tabler="IconArrowBackUp"
phosphor="ArrowCounterClockwise"
remixicon="RiArrowGoBackLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
<p className="text-xs font-medium">Undo</p>
<p className="text-[10px] text-muted-foreground">Ctrl+Z</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
onClick={() => redo()}
disabled={!canRedo}
className={cn(
"h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/50",
!canRedo && "opacity-50 cursor-not-allowed",
)}
>
<IconPlaceholder
lucide="Redo"
hugeicons="RedoIcon"
tabler="IconArrowForwardUp"
phosphor="ArrowClockwise"
remixicon="RiArrowGoForwardLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
<p className="text-xs font-medium">Redo</p>
<p className="text-[10px] text-muted-foreground">Ctrl+Shift+Z</p>
</TooltipContent>
</Tooltip>
</div>
<div className="mx-1.5 h-6 w-px bg-border/50" />
{/* Mode Toggle */}
<Tooltip>
<TooltipTrigger
render={
<Button
variant={isPreview ? "default" : "ghost"}
size="icon"
onClick={() => setMode(isPreview ? "edit" : "preview")}
className={cn(
"h-8 w-8 rounded-full",
!isPreview &&
"text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
{isPreview ? (
<IconPlaceholder
lucide="Pencil"
hugeicons="PencilEdit01Icon"
tabler="IconPencil"
phosphor="Pencil"
remixicon="RiPencilLine"
size={16}
/>
) : (
<IconPlaceholder
lucide="Eye"
hugeicons="ViewIcon"
tabler="IconEye"
phosphor="Eye"
remixicon="RiEyeLine"
size={16}
/>
)}
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
<p className="text-xs font-medium">
{isPreview ? "Back to Edit Mode" : "Preview Form"}
</p>
</TooltipContent>
</Tooltip>
<div className="mx-1.5 h-6 w-px bg-border/50" />
{/* Viewport Toggles */}
<div className="flex items-center gap-0.5">
{/* Desktop */}
<Tooltip>
<TooltipTrigger
render={
<Button
variant={viewport === "desktop" ? "default" : "ghost"}
size="icon"
onClick={() => setViewport("desktop")}
className={cn(
"h-8 w-8 rounded-full",
viewport !== "desktop" &&
"text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
<IconPlaceholder
lucide="Monitor"
hugeicons="ComputerIcon"
tabler="IconDeviceDesktop"
phosphor="Monitor"
remixicon="RiComputerLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
<p className="text-xs font-medium">Desktop (100%)</p>
</TooltipContent>
</Tooltip>
{/* Tablet */}
<Tooltip>
<TooltipTrigger
render={
<Button
variant={viewport === "tablet" ? "default" : "ghost"}
size="icon"
onClick={() => setViewport("tablet")}
className={cn(
"h-8 w-8 rounded-full",
viewport !== "tablet" &&
"text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
<IconPlaceholder
lucide="Tablet"
hugeicons="Tablet01Icon"
tabler="IconDeviceTablet"
phosphor="DeviceTabletIcon"
remixicon="RiTabletLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
<p className="text-xs font-medium">Tablet (768px)</p>
</TooltipContent>
</Tooltip>
{/* Mobile */}
<Tooltip>
<TooltipTrigger
render={
<Button
variant={viewport === "mobile" ? "default" : "ghost"}
size="icon"
onClick={() => setViewport("mobile")}
className={cn(
"h-8 w-8 rounded-full",
viewport !== "mobile" &&
"text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
<IconPlaceholder
lucide="Smartphone"
hugeicons="SmartPhone01Icon"
tabler="IconDeviceMobile"
phosphor="DeviceMobile"
remixicon="RiSmartphoneLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
<p className="text-xs font-medium">Mobile (375px)</p>
</TooltipContent>
</Tooltip>
</div>
<div className="mx-1.5 h-6 w-px bg-border/50" />
{/* Zoom Controls */}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/50"
onClick={() => setZoom(zoom - 0.1)}
>
<IconPlaceholder
lucide="Minus"
hugeicons="MinusSignIcon"
tabler="IconMinus"
phosphor="Minus"
remixicon="RiSubtractLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
Zoom Out
</TooltipContent>
</Tooltip>
<div className="flex items-center justify-center min-w-12 px-1 text-xs font-semibold tabular-nums text-muted-foreground select-none">
{Math.round(zoom * 100)}%
</div>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/50"
onClick={() => setZoom(zoom + 0.1)}
>
<IconPlaceholder
lucide="Plus"
hugeicons="Add01Icon"
tabler="IconPlus"
phosphor="Plus"
remixicon="RiAddLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
Zoom In
</TooltipContent>
</Tooltip>
{/* Reset Zoom */}
<div className="mx-1 h-4 w-px bg-border/50" />
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
disabled={Math.round(zoom * 100) === 90}
className={cn(
"h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/50",
Math.round(zoom * 100) === 90 &&
"opacity-50 cursor-not-allowed",
)}
onClick={() => setZoom(0.9)}
>
<IconPlaceholder
lucide="RotateCcw"
hugeicons="ArrowTurnBackwardIcon"
tabler="IconRefresh"
phosphor="ArrowCounterClockwise"
remixicon="RiRefreshLine"
size={16}
/>
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
Reset Zoom
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
);
}
registry/shadcn/builder/drag-overlay.tsx
"use client";
import {
useBuilderStore,
useBuilderContext,
} from "@buildnbuzz/form-builder-react";
import { FieldIcon } from "./field-icon";
import { cn } from "@/lib/utils";
/**
* Rich drag overlay item that provides visual feedback during DnD.
* Matches legacy DragOverlayItem but integrates with the new registry pattern.
*/
export function DragOverlayItem({
activeId,
activeData,
}: {
activeId: string;
activeData: Record<string, unknown>;
}) {
const { registry } = useBuilderContext();
const node = useBuilderStore((s) => s.nodes[activeId]);
const data = activeData as Record<string, unknown>;
const isFromSidebar = data?.from === "sidebar";
const type = (isFromSidebar ? data?.type : node?.field?.type) as
| string
| undefined;
const entry = type ? registry[type] : null;
const label =
entry?.sidebar?.label ?? (typeof type === "string" ? type : "Field");
// Get field name for existing fields
let fieldName: string | undefined;
if (!isFromSidebar && node && "name" in node.field) {
fieldName = node.field.name;
}
return (
<div
className={cn(
"bg-card/95 backdrop-blur-md border-2 shadow-2xl rounded-xl p-3 min-w-[20rem] cursor-grabbing",
"animate-in fade-in-0 zoom-in-95 duration-150",
isFromSidebar
? "border-primary/50 shadow-primary/10"
: "border-border shadow-black/10",
)}
>
<div className="flex items-center gap-3">
{/* Icon container */}
<div
className={cn(
"p-2.5 rounded-lg flex items-center justify-center",
isFromSidebar
? "bg-primary/15 text-primary"
: "bg-muted text-foreground",
)}
>
{type ? (
<FieldIcon type={type} size={20} />
) : (
<FieldIcon type="text" size={20} />
)}
</div>
{/* Text content */}
<div className="flex-1 min-w-0 text-left">
<div className="text-sm font-semibold text-foreground truncate">
{isFromSidebar ? `New ${label}` : (fieldName ?? label)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5 whitespace-nowrap">
<FieldIcon
type={isFromSidebar ? "plus" : "move"}
size={12}
/>
<div className="flex items-center gap-1">
{isFromSidebar ? (
<>
Adding new, press
<kbd className="inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-foreground/80">
Esc
</kbd>
to cancel
</>
) : (
"Moving field"
)}
</div>
</div>
</div>
</div>
</div>
);
}
registry/shadcn/builder/export-sheet.tsx
"use client";
import * as React from "react";
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { IconPlaceholder } from "@/components/icon-placeholder";
import {
generateComponentCode,
nodesToFields,
toSafeFileName,
} from "@buildnbuzz/form-builder-core";
import {
useBuilderStore,
downloadTextFile,
} from "@buildnbuzz/form-builder-react";
const starterCommand =
"npx shadcn@latest add https://form.buildnbuzz.com/r/all.json";
export function ExportSheet() {
const [open, setOpen] = React.useState(false);
const nodes = useBuilderStore((state) => state.nodes);
const rootIds = useBuilderStore((state) => state.rootIds);
const formName = useBuilderStore((state) => state.formName);
const outputConfig = useBuilderStore((state) => state.outputConfig);
const componentCode = React.useMemo(() => {
if (!open) return "";
return generateComponentCode(nodes, rootIds, formName, outputConfig);
}, [open, nodes, rootIds, formName, outputConfig]);
const schemaJson = React.useMemo(() => {
if (!open) return "";
const fields = nodesToFields(nodes, rootIds);
const schema = {
title: formName || "BuzzForm Export",
fields,
};
return JSON.stringify(schema, null, 2);
}, [open, nodes, rootIds, formName]);
const downloadSchema = React.useCallback(() => {
if (!schemaJson) return;
const fileName = `${toSafeFileName(formName)}.json`;
downloadTextFile(schemaJson, fileName, "application/json");
toast.success("BuzzForm schema exported");
}, [schemaJson, formName]);
const downloadCode = React.useCallback(() => {
if (!componentCode) return;
const fileName = `${toSafeFileName(formName)}.tsx`;
downloadTextFile(componentCode, fileName, "text/plain;charset=utf-8");
toast.success("Component code exported");
}, [componentCode, formName]);
const copyToClipboard = React.useCallback((text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
}, []);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button variant="outline" className="gap-2">
<IconPlaceholder
hugeicons="FileExportIcon"
lucide="FileDown"
tabler="IconFileExport"
phosphor="FileArrowDown"
remixicon="RiFileDownloadLine"
size={16}
strokeWidth={2}
/>
Export
</Button>
}
/>
<SheetContent
side="right"
className="w-full p-0 gap-0 data-[side=right]:w-[min(96vw,1120px)] data-[side=right]:sm:max-w-280"
>
<SheetHeader className="border-b pr-12">
<SheetTitle>Export Form</SheetTitle>
<SheetDescription>
Export production-ready TSX or portable BuzzForm schema JSON.
</SheetDescription>
</SheetHeader>
<Tabs
defaultValue="tsx"
className="flex h-full min-h-0 flex-1 flex-col p-4"
>
<TabsList variant="line" className="w-full">
<TabsTrigger value="tsx" className="gap-1.5">
<IconPlaceholder
hugeicons="SourceCodeIcon"
lucide="Code"
tabler="IconCode"
phosphor="Code"
remixicon="RiCodeLine"
size={16}
strokeWidth={2}
/>
App Code
<Badge variant="outline">.tsx</Badge>
</TabsTrigger>
<TabsTrigger value="schema" className="gap-1.5">
<IconPlaceholder
hugeicons="File02Icon"
lucide="File"
tabler="IconFile"
phosphor="File"
remixicon="RiFileCodeLine"
size={16}
strokeWidth={2}
/>
BuzzForm Schema
<Badge variant="outline">.json</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="tsx" className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full px-4 py-2">
<div className="space-y-3 p-1">
<div className="rounded-lg border bg-muted/30 p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<IconPlaceholder
hugeicons="SourceCodeIcon"
lucide="Code"
tabler="IconCode"
phosphor="Code"
remixicon="RiCodeLine"
size={16}
strokeWidth={1.8}
className="text-muted-foreground"
/>
Required setup
</div>
<div className="[&_figure]:my-0!">
<DynamicCodeBlock lang="bash" code={starterCommand} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={downloadCode}
>
<IconPlaceholder
hugeicons="Download01Icon"
lucide="Download"
tabler="IconDownload"
phosphor="DownloadSimple"
remixicon="RiDownload2Line"
size={16}
strokeWidth={2}
/>
Download TSX File
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => copyToClipboard(componentCode, "App code")}
>
<IconPlaceholder
hugeicons="Copy01Icon"
lucide="Copy"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={16}
strokeWidth={2}
/>
Copy Code
</Button>
</div>
</div>
<div className="[&_figure]:my-0!">
<DynamicCodeBlock lang="tsx" code={componentCode} />
</div>
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="schema"
className="min-h-0 flex-1 overflow-hidden"
>
<ScrollArea className="h-full px-4 py-2">
<div className="space-y-3 p-1">
<div className="rounded-lg border bg-muted/30 p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<IconPlaceholder
hugeicons="File02Icon"
lucide="File"
tabler="IconFile"
phosphor="File"
remixicon="RiFileCodeLine"
size={16}
strokeWidth={1.8}
className="text-muted-foreground"
/>
BuzzForm schema
</div>
<p className="text-sm text-muted-foreground">
Pure schema JSON for copy/paste import and runtime form
generation.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={downloadSchema}
>
<IconPlaceholder
hugeicons="Download01Icon"
lucide="Download"
tabler="IconDownload"
phosphor="DownloadSimple"
remixicon="RiDownload2Line"
size={16}
strokeWidth={2}
/>
Download BuzzForm Schema
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => copyToClipboard(schemaJson, "Schema JSON")}
>
<IconPlaceholder
hugeicons="Copy01Icon"
lucide="Copy"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={16}
strokeWidth={2}
/>
Copy JSON
</Button>
</div>
</div>
<div className="[&_figure]:my-0!">
<DynamicCodeBlock lang="json" code={schemaJson} />
</div>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}
registry/shadcn/builder/expression-builder/expression-checkbox.tsx
"use client";
import React from "react";
import { useDataField } from "@buildnbuzz/form-react";
import type { ExpressionGroup } from "@buildnbuzz/form-builder-core";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { IconPlaceholder } from "@/components/icon-placeholder";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { cn } from "@/lib/utils";
import { ExpressionBuilder } from "./index";
type ExpressionValue = boolean | undefined | ExpressionGroup;
function isExpressionGroup(value: unknown): value is ExpressionGroup {
return (
typeof value === "object" &&
value !== null &&
(value as ExpressionGroup).type === "group"
);
}
/**
* Dual-mode property widget for boolean/expression fields.
*
* - Static mode: renders a Checkbox or Switch (per `field.type`) with a ⚡ button.
* - Expression mode: renders an "Expression active" badge with Edit and Clear buttons.
*
* Toggling ⚡ opens the ExpressionBuilder dialog. Saving stores an ExpressionGroup.
* Clearing reverts to `false` (static boolean).
*/
export function ExpressionCheckbox() {
const {
field,
fieldApi,
isDisabled,
isReadOnly,
isRequired,
label,
description,
errors,
isInvalid,
descriptionId,
errorId,
ariaDescribedBy,
handleBlur,
handleChange,
} = useDataField();
const value = fieldApi.state.value as ExpressionValue;
const disabled = isDisabled || isReadOnly;
const isExpression = isExpressionGroup(value);
const boolValue = isExpression ? false : ((value as boolean | undefined) ?? false);
const fieldType = (field as { type?: string }).type;
const ui = (
field as {
ui?: {
alignment?: "start" | "end" | "between";
autoFocus?: boolean;
className?: string;
expressionMode?: "default" | "only" | "none";
};
}
).ui;
const alignment = ui?.alignment ?? "between";
const expressionMode = ui?.expressionMode ?? "default";
const handleSave = (group: ExpressionGroup) => handleChange(group);
const handleClear = () => handleChange(expressionMode === "only" ? undefined : false);
// 1. The active expression node (replaces the input)
const activeControlNode = (
<div
className={cn(
"flex shrink-0 items-center justify-center border border-amber-500/50 bg-amber-500/10 text-amber-600 dark:text-amber-500",
fieldType === "switch" ? "h-5 w-9 rounded-full" : "h-4 w-4 mt-0.5 rounded-sm"
)}
>
<IconPlaceholder
lucide="Zap"
hugeicons="Lightning"
tabler="IconBolt"
phosphor="Lightning"
remixicon="RiFlashlightLine"
size={fieldType === "switch" ? 12 : 10}
className="fill-amber-500/20"
/>
</div>
);
const expressionBar = isExpression ? (
<div className="mt-2 flex items-center justify-between rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-1 shadow-sm">
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-500">
<IconPlaceholder
lucide="Zap"
hugeicons="Lightning"
tabler="IconBolt"
phosphor="Lightning"
remixicon="RiFlashlightLine"
size={14}
className="fill-amber-500/20"
/>
Dynamic Expression
</div>
<div className="flex items-center gap-0.5">
<ExpressionBuilder
initialValue={value as ExpressionGroup}
onSave={handleSave}
trigger={
<Button
variant="ghost"
size="icon-sm"
className="h-6 w-6 text-amber-600/70 hover:bg-amber-500/10 hover:text-amber-600 dark:text-amber-500/70 dark:hover:text-amber-500"
>
<IconPlaceholder
lucide="Pencil"
hugeicons="PencilEdit01Icon"
tabler="IconPencil"
phosphor="PencilSimple"
remixicon="RiPencilLine"
size={12}
/>
</Button>
}
/>
<Button
variant="ghost"
size="icon-sm"
className="h-6 w-6 text-amber-600/70 hover:bg-destructive/10 hover:text-destructive dark:text-amber-500/70 dark:hover:text-destructive"
onClick={handleClear}
disabled={disabled}
>
<IconPlaceholder
lucide="X"
hugeicons="Cancel01Icon"
tabler="IconX"
phosphor="X"
remixicon="RiCloseLine"
size={12}
/>
</Button>
</div>
</div>
) : expressionMode === "only" && !isExpression ? (
<div className="mt-2">
<ExpressionBuilder
initialValue={undefined}
onSave={handleSave}
trigger={
<Button
variant="outline"
size="sm"
className="h-7 w-full gap-1.5 border-dashed text-xs text-muted-foreground hover:border-amber-500/50 hover:bg-amber-500/5 hover:text-amber-500"
>
<IconPlaceholder
lucide="Zap"
hugeicons="Lightning"
tabler="IconBolt"
phosphor="Lightning"
remixicon="RiFlashlightLine"
size={14}
/>
Set Expression
</Button>
}
/>
</div>
) : null;
// 2. The static control node
const switchNode = (
<Switch
id={fieldApi.name}
checked={boolValue}
onCheckedChange={(checked) => {
handleChange(checked);
handleBlur();
}}
disabled={disabled}
aria-invalid={isInvalid}
aria-describedby={ariaDescribedBy}
aria-readonly={isReadOnly}
autoFocus={ui?.autoFocus}
size="sm"
/>
);
const checkboxNode = (
<Checkbox
id={fieldApi.name}
checked={boolValue}
onCheckedChange={(checked) => {
handleChange(!!checked);
handleBlur();
}}
disabled={disabled}
aria-invalid={isInvalid}
aria-describedby={ariaDescribedBy}
aria-readonly={isReadOnly}
autoFocus={ui?.autoFocus}
/>
);
const emptyControlNode = (
<div
className={cn(
"flex shrink-0 items-center justify-center text-muted-foreground/30",
fieldType === "switch" ? "h-5 w-9" : "h-4 w-4 mt-0.5"
)}
>
<IconPlaceholder
lucide="Zap"
hugeicons="Lightning"
tabler="IconBolt"
phosphor="Lightning"
remixicon="RiFlashlightLine"
size={fieldType === "switch" ? 12 : 10}
/>
</div>
);
const controlNode = isExpression
? activeControlNode
: expressionMode === "only"
? emptyControlNode
: fieldType === "switch"
? switchNode
: checkboxNode;
// 3. The content element (label, description, error, expression bar)
const isSwitchBetween = fieldType === "switch" && alignment === "between";
const contentElement = (
<FieldContent className={isSwitchBetween ? "flex-1" : undefined}>
{label && (
<FieldLabel
htmlFor={fieldApi.name}
className={cn(
"cursor-pointer m-0 font-normal",
isSwitchBetween ? "flex-none gap-1 items-baseline" : "inline"
)}
>
{isSwitchBetween && isRequired && <span className="text-destructive">*</span>}
{label}
{!isSwitchBetween && isRequired && <span className="text-destructive"> *</span>}
</FieldLabel>
)}
{description && !isInvalid && (
<FieldDescription id={descriptionId}>{description}</FieldDescription>
)}
{isInvalid && <FieldError id={errorId} errors={errors} />}
{expressionBar}
</FieldContent>
);
// 4. Assemble the layout
return (
<FieldGroup data-field={fieldApi.name} className={cn("group/expr", ui?.className)}>
<div className="flex items-start gap-2 w-full">
<Field
orientation="horizontal"
className={cn("flex-1", isSwitchBetween && "w-full justify-between")}
data-invalid={isInvalid}
data-disabled={disabled}
>
{fieldType === "switch" ? (
alignment === "start" ? (
<>
{controlNode}
{contentElement}
</>
) : (
<>
{contentElement}
{controlNode}
</>
)
) : (
// Default Checkbox layout
<>
{controlNode}
{contentElement}
</>
)}
</Field>
{/* Lightning bolt trigger (only visible when static mode and default mode) */}
{!isExpression && expressionMode === "default" && (
<ExpressionBuilder
initialValue={undefined}
onSave={handleSave}
trigger={
<Button
variant="ghost"
size="icon-sm"
className="h-6 w-6 shrink-0 opacity-0 transition-all hover:bg-amber-500/10 hover:text-amber-500 focus-visible:opacity-100 group-hover/expr:opacity-100 dark:text-muted-foreground"
disabled={disabled}
>
<IconPlaceholder
lucide="Zap"
hugeicons="Lightning"
tabler="IconBolt"
phosphor="Lightning"
remixicon="RiFlashlightLine"
size={14}
/>
</Button>
}
/>
)}
</div>
</FieldGroup>
);
}
registry/shadcn/builder/expression-builder/expression-group.tsx
"use client";
import React from "react";
import type { StoreApi } from "zustand";
import { cn } from "@/lib/utils";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyMedia,
} from "@/components/ui/empty";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { type ExpressionGroup } from "@buildnbuzz/form-builder-core";
import { type ExpressionStoreState } from "@buildnbuzz/form-builder-react";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { SortableItem } from "./sortable-item";
import { ExpressionRuleItem } from "./expression-rule";
interface ExpressionGroupItemProps {
group: ExpressionGroup;
parentId?: string;
isRoot?: boolean;
store: StoreApi<ExpressionStoreState>;
}
export function ExpressionGroupItem({
group,
parentId,
isRoot = false,
store,
}: ExpressionGroupItemProps) {
const [isOpen, setIsOpen] = React.useState(true);
const {
updateGroupOperator,
removeNode,
addRule,
addGroup,
duplicateGroup,
} = store.getState();
const headerContent = (
<div
className={cn(
"flex flex-col sm:flex-row items-start sm:items-center gap-2 p-1.5 transition-all relative",
isRoot
? "rounded-xl"
: cn(
"bg-muted/50 cursor-pointer group/header hover:bg-muted/80 rounded-t-xl",
isOpen ? "rounded-b-none" : "rounded-b-xl"
)
)}
>
{!isRoot && (
<div className="flex items-center justify-center w-6 h-6 shrink-0 text-muted-foreground mr-1">
{isOpen ? (
<IconPlaceholder
lucide="ChevronDown"
hugeicons="ArrowDown01Icon"
tabler="IconChevronDown"
phosphor="CaretDown"
remixicon="RiArrowDownSLine"
size={18}
/>
) : (
<IconPlaceholder
lucide="ChevronRight"
hugeicons="ArrowRight01Icon"
tabler="IconChevronRight"
phosphor="CaretRight"
remixicon="RiArrowRightSLine"
size={18}
/>
)}
</div>
)}
<div onClick={(e) => e.stopPropagation()}>
<Select
value={group.logicalOperator}
onValueChange={(val: unknown) => {
if (val) updateGroupOperator(group.id, val as "AND" | "OR");
}}
>
<SelectTrigger className="w-26.25 text-xs font-medium bg-muted border-none">
<SelectValue>
{group.logicalOperator === "AND" ? "Match ALL" : "Match ANY"}
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
align="start"
sideOffset={4}
>
<SelectItem value="AND">Match ALL</SelectItem>
<SelectItem value="OR">Match ANY</SelectItem>
</SelectContent>
</Select>
</div>
<span className="text-sm font-medium text-muted-foreground">
{isRoot ? "of the following:" : "Expression Group"}
</span>
{!isRoot && parentId && (
<div
className="absolute -top-3 right-4 flex items-center bg-card border shadow-sm rounded-md px-1 py-0.5 opacity-0 group-hover/header:opacity-100 transition-all z-30 scale-95 group-hover/header:scale-100"
onClick={(e) => e.stopPropagation()}
>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-lg"
className="h-7 w-7 text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
duplicateGroup(parentId, group.id);
}}
>
<IconPlaceholder
lucide="Copy"
hugeicons="Copy01Icon"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={16}
/>
</Button>
}
/>
<TooltipContent>Duplicate Expression Group</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-0.5" />
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeNode(parentId, group.id);
}}
>
<IconPlaceholder
lucide="Trash2"
hugeicons="Delete01Icon"
tabler="IconTrash"
phosphor="Trash"
remixicon="RiDeleteBinLine"
size={16}
/>
</Button>
}
/>
<TooltipContent>Delete Expression Group</TooltipContent>
</Tooltip>
</div>
)}
</div>
);
const mainContent =
group.children.length === 0 ? (
<Empty className={isRoot ? "h-full min-h-75" : "h-auto py-8"}>
<EmptyHeader>
<EmptyMedia variant="icon">
<IconPlaceholder
lucide="Layers"
hugeicons="Layers01Icon"
tabler="IconLayersIntersect"
phosphor="Stack"
remixicon="RiStackLine"
/>
</EmptyMedia>
<EmptyTitle>No rules added</EmptyTitle>
<EmptyDescription>
Click the buttons below to add rules
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<SortableContext
id={group.id}
items={group.children.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-3">
{group.children.map((child) => (
<SortableItem key={child.id} id={child.id}>
{child.type === "group" ? (
<ExpressionGroupItem
group={child}
parentId={group.id}
store={store}
/>
) : (
<ExpressionRuleItem
rule={child}
parentId={group.id}
store={store}
/>
)}
</SortableItem>
))}
</div>
</SortableContext>
);
const footerActions = (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => addRule(group.id)}
>
<span className="mr-1 flex items-center justify-center">
<IconPlaceholder
lucide="Plus"
hugeicons="PlusSignIcon"
tabler="IconPlus"
phosphor="Plus"
remixicon="RiAddLine"
size={14}
/>
</span>
Add Rule
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => addGroup(group.id)}
>
<span className="mr-1 flex items-center justify-center">
<IconPlaceholder
lucide="FolderPlus"
hugeicons="FolderAddIcon"
tabler="IconFolderPlus"
phosphor="FolderPlus"
remixicon="RiFolderAddLine"
size={14}
/>
</span>
Add Group
</Button>
</div>
);
if (isRoot) {
return (
<div className="flex flex-col flex-1 min-h-0 h-full">
<div className="shrink-0 px-6 pt-0 pb-0">{headerContent}</div>
<ScrollArea className="flex-1 h-full min-h-0">
<div className="px-6 pt-4 pb-2">{mainContent}</div>
</ScrollArea>
<div className="shrink-0 px-6 pt-2 pb-0">{footerActions}</div>
</div>
);
}
return (
<div className="overflow-visible w-full">
<Card
size="sm"
className="gap-0 py-0 data-[size=sm]:py-0 ring-1 ring-border shadow-sm border-none bg-card overflow-visible"
>
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full">
<CollapsibleTrigger
nativeButton={false}
render={headerContent}
className="w-full"
/>
<CollapsibleContent>
<CardContent className="pt-2 pb-0">{mainContent}</CardContent>
<CardFooter className="bg-transparent border-none px-4 py-2">
{footerActions}
</CardFooter>
</CollapsibleContent>
</Collapsible>
</Card>
</div>
);
}
registry/shadcn/builder/expression-builder/expression-rule.tsx
"use client";
import type { StoreApi } from "zustand";
import {
type ExpressionRule,
type ExpressionOperator,
EXPRESSION_OPERATORS,
getFieldTypeCategory,
getOperatorsForFieldType,
} from "@buildnbuzz/form-builder-core";
import {
useAvailableFields,
type ExpressionStoreState,
} from "@buildnbuzz/form-builder-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { IconPlaceholder } from "@/components/icon-placeholder";
interface ExpressionRuleItemProps {
rule: ExpressionRule;
parentId: string;
store: StoreApi<ExpressionStoreState>;
}
export function ExpressionRuleItem({
rule,
parentId,
store,
}: ExpressionRuleItemProps) {
const availableFields = useAvailableFields();
const selectedField = availableFields.find((f) => f.id === rule.fieldId);
const fieldCategory = getFieldTypeCategory(selectedField?.type);
const applicableOperators = getOperatorsForFieldType(selectedField?.type);
const selectedOperator = EXPRESSION_OPERATORS.find(
(op) => op.value === rule.operator,
);
const requiresValue = selectedOperator?.requiresValue ?? true;
const selectedValueField = availableFields.find((f) => f.id === rule.value);
const { updateRule, removeNode, duplicateRule } = store.getState();
const handleFieldChange = (newFieldId: string) => {
const newField = availableFields.find((f) => f.id === newFieldId);
const newCategory = getFieldTypeCategory(newField?.type);
const newOps = getOperatorsForFieldType(newField?.type);
// Reset operator if not applicable to new field type
const currentOpApplicable = newOps.some((op) => op.value === rule.operator);
const newOperator = currentOpApplicable ? rule.operator : "equals";
// Reset value for type changes
let newValue = rule.value;
if (newCategory === "boolean") {
newValue = "true";
} else if (newCategory !== fieldCategory) {
newValue = "";
}
updateRule(parentId, rule.id, {
fieldId: newFieldId,
operator: newOperator,
value: newValue,
valueType: newCategory === "boolean" ? "custom" : rule.valueType,
});
};
// Render value input based on field type category
const renderValueInput = () => {
if (!requiresValue) return null;
// Field reference mode
if (rule.valueType !== "custom") {
return (
<div className="flex items-center gap-2 w-full sm:flex-1">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-lg"
className="shrink-0 text-muted-foreground"
onClick={() =>
updateRule(parentId, rule.id, {
valueType: "custom",
value: selectedField?.options?.[0]?.value ?? "",
})
}
>
<IconPlaceholder
lucide="Link"
hugeicons="LinkSquare02Icon"
tabler="IconLink"
phosphor="Link"
remixicon="RiLinksLine"
size={16}
/>
</Button>
}
/>
<TooltipContent>Use Custom Value</TooltipContent>
</Tooltip>
<Select
value={rule.value}
onValueChange={(val: unknown) => {
if (val)
updateRule(parentId, rule.id, {
value: val as string,
});
}}
>
<SelectTrigger className="w-full h-9!">
<SelectValue>{selectedValueField?.label}</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
align="start"
sideOffset={4}
>
{availableFields
.filter((f) => f.id !== rule.fieldId)
.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
const fieldRefButton = (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-lg"
className="shrink-0 text-muted-foreground"
onClick={() =>
updateRule(parentId, rule.id, {
valueType: "field",
value: "",
})
}
>
<IconPlaceholder
lucide="Type"
hugeicons="TextIcon"
tabler="IconLetterCase"
phosphor="TextT"
remixicon="RiText"
size={16}
/>
</Button>
}
/>
<TooltipContent>Use Field Reference</TooltipContent>
</Tooltip>
);
// Fields with options (boolean, select, radio, checkbox-group): render Select dropdown
if (selectedField?.options && selectedField.options.length > 0) {
const matchedOption = selectedField.options.find(
(o) => o.value === rule.value,
);
return (
<div className="flex items-center gap-2 w-full sm:flex-1">
{fieldRefButton}
<Select
value={rule.value}
onValueChange={(val: unknown) => {
if (val) updateRule(parentId, rule.id, { value: val as string });
}}
>
<SelectTrigger className="w-full h-9!">
<SelectValue>{matchedOption?.label ?? rule.value}</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
align="start"
sideOffset={4}
>
{selectedField.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// Number fields: render number input
if (fieldCategory === "number") {
return (
<div className="flex items-center gap-2 w-full sm:flex-1">
{fieldRefButton}
<Input
type="number"
placeholder="Enter number..."
className="h-9 w-full"
value={rule.value}
onChange={(e) =>
updateRule(parentId, rule.id, { value: e.target.value })
}
/>
</div>
);
}
// Default: text input
return (
<div className="flex items-center gap-2 w-full sm:flex-1">
{fieldRefButton}
<Input
placeholder="Enter value..."
className="h-9 w-full"
value={rule.value}
onChange={(e) =>
updateRule(parentId, rule.id, { value: e.target.value })
}
/>
</div>
);
};
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 bg-card p-2 rounded-lg border shadow-sm transition-all group/rule w-full relative">
<Select
value={rule.fieldId}
onValueChange={(val: unknown) => {
if (val) handleFieldChange(val as string);
}}
>
<SelectTrigger className="w-full sm:w-50 h-9!">
<SelectValue>{selectedField?.label}</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
align="start"
sideOffset={4}
>
{availableFields.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={rule.operator}
onValueChange={(val: unknown) => {
if (val)
updateRule(parentId, rule.id, {
operator: val as ExpressionOperator,
});
}}
>
<SelectTrigger className="w-full sm:w-40 h-9!">
<SelectValue>{selectedOperator?.label}</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
align="start"
sideOffset={4}
>
{applicableOperators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{renderValueInput()}
<div className="absolute -top-3 right-4 flex items-center bg-card border shadow-sm rounded-md px-1 py-0.5 opacity-0 group-hover/rule:opacity-100 transition-all z-50 scale-95 group-hover/rule:scale-100">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-lg"
className="text-muted-foreground hover:text-primary h-7 w-7"
onClick={() => duplicateRule(parentId, rule.id)}
>
<IconPlaceholder
lucide="Copy"
hugeicons="Copy01Icon"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={16}
/>
</Button>
}
/>
<TooltipContent>Duplicate Rule</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-0.5" />
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-7 w-7"
onClick={() => removeNode(parentId, rule.id)}
>
<IconPlaceholder
lucide="Trash2"
hugeicons="Delete01Icon"
tabler="IconTrash"
phosphor="Trash"
remixicon="RiDeleteBinLine"
size={16}
/>
</Button>
}
/>
<TooltipContent>Delete Rule</TooltipContent>
</Tooltip>
</div>
</div>
);
}
registry/shadcn/builder/expression-builder/index.tsx
"use client";
import React, { useState } from "react";
import { DndContext, closestCorners } from "@dnd-kit/core";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { IconPlaceholder } from "@/components/icon-placeholder";
import type { ExpressionGroup } from "@buildnbuzz/form-builder-core";
import {
useExpressionStore,
useExpressionDnd,
} from "@buildnbuzz/form-builder-react";
import { ExpressionGroupItem } from "./expression-group";
export { ExpressionGroupItem } from "./expression-group";
export { ExpressionRuleItem } from "./expression-rule";
export { SortableItem } from "./sortable-item";
export interface ExpressionBuilderProps {
initialValue?: ExpressionGroup;
onSave: (group: ExpressionGroup) => void;
trigger?: React.ReactElement;
}
export function ExpressionBuilder({
initialValue,
onSave,
trigger,
}: ExpressionBuilderProps) {
const [open, setOpen] = useState(false);
const { store, rootGroup } = useExpressionStore(initialValue, open);
const { sensors, handleDragOver, handleDragEnd } = useExpressionDnd(store);
const handleSave = () => {
onSave(store.getState().rootGroup);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
trigger || (
<Button variant="outline">
<span className="mr-2 flex items-center justify-center">
<IconPlaceholder
lucide="Settings"
hugeicons="Settings01Icon"
tabler="IconSettings"
phosphor="Gear"
remixicon="RiSettings4Line"
size={16}
/>
</span>
Expression Logic
</Button>
)
}
/>
<DialogContent className="sm:max-w-4xl md:max-w-5xl lg:max-w-6xl w-[95vw] h-[80vh] flex flex-col p-0 overflow-hidden bg-background/80">
<DialogHeader className="p-6 pb-4 bg-card border-b shrink-0 flex flex-row items-start gap-4 space-y-0 text-left">
<div className="text-primary flex items-center justify-center p-2 rounded-xl bg-primary/10">
<IconPlaceholder
lucide="Settings"
hugeicons="Settings01Icon"
tabler="IconSettings"
phosphor="Gear"
remixicon="RiSettings4Line"
size={24}
/>
</div>
<div className="flex flex-col gap-1">
<DialogTitle>Expression Builder</DialogTitle>
<DialogDescription>
Define the logical expression for this property.
</DialogDescription>
</div>
</DialogHeader>
<div className="flex-1 flex flex-col min-h-0">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<ExpressionGroupItem
group={rootGroup}
isRoot={true}
store={store}
/>
</DndContext>
</div>
<DialogFooter className="border-t shrink-0 m-0 rounded-none">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
Save Expression
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
registry/shadcn/builder/expression-builder/sortable-item.tsx
"use client";
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { IconPlaceholder } from "@/components/icon-placeholder";
interface SortableItemProps {
id: string;
children: React.ReactNode;
}
export function SortableItem({ id, children }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-start gap-2 relative group/item"
>
<div
{...attributes}
{...listeners}
className="mt-2 cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground opacity-0 group-hover/item:opacity-100 transition-opacity"
>
<IconPlaceholder
lucide="GripVertical"
hugeicons="DragDropVerticalIcon"
tabler="IconGripVertical"
phosphor="DotsSixVertical"
remixicon="RiDragMove2Line"
size={20}
/>
</div>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
registry/shadcn/builder/field-icon.tsx
"use client";
import React from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
interface FieldIconProps extends React.ComponentProps<"svg"> {
type: string;
size?: number;
}
/**
* A statically-analyzable icon picker for the form builder.
*
* IMPORTANT: We use a switch statement with literal string props for IconPlaceholder
* so that the shadcn CLI transformer can identify and replace these with the
* user's local icon library components during installation.
*/
export const FieldIcon = ({ type, size = 16, ...props }: FieldIconProps) => {
switch (type) {
case "text":
return (
<IconPlaceholder
lucide="Type"
hugeicons="TextIcon"
tabler="IconLetterCase"
phosphor="TextT"
remixicon="RiText"
size={size}
{...props}
/>
);
case "email":
return (
<IconPlaceholder
lucide="Mail"
hugeicons="Mail01Icon"
tabler="IconMail"
phosphor="Envelope"
remixicon="RiMailLine"
size={size}
{...props}
/>
);
case "password":
return (
<IconPlaceholder
lucide="Lock"
hugeicons="SecurityPasswordIcon"
tabler="IconLock"
phosphor="LockSimple"
remixicon="RiLockPasswordLine"
size={size}
{...props}
/>
);
case "textarea":
return (
<IconPlaceholder
lucide="AlignLeft"
hugeicons="TextAlignLeftIcon"
tabler="IconAlignLeft"
phosphor="TextAlignLeft"
remixicon="RiAlignLeft"
size={size}
{...props}
/>
);
case "number":
return (
<IconPlaceholder
lucide="Binary"
hugeicons="GridIcon"
tabler="IconHash"
phosphor="Hash"
remixicon="RiNumber7"
size={size}
{...props}
/>
);
case "checkbox":
return (
<IconPlaceholder
lucide="CheckSquare"
hugeicons="CheckmarkSquare02Icon"
tabler="IconCheck"
phosphor="Check"
remixicon="RiCheckLine"
size={size}
{...props}
/>
);
case "switch":
return (
<IconPlaceholder
lucide="ToggleRight"
hugeicons="ToggleOnIcon"
tabler="IconToggleRight"
phosphor="ToggleRight"
remixicon="RiToggleLine"
size={size}
{...props}
/>
);
case "select":
return (
<IconPlaceholder
lucide="ChevronDown"
hugeicons="ArrowDown01Icon"
tabler="IconChevronDown"
phosphor="CaretDown"
remixicon="RiArrowDownSLine"
size={size}
{...props}
/>
);
case "radio":
return (
<IconPlaceholder
lucide="CircleDot"
hugeicons="CircleIcon"
tabler="IconCircleDot"
phosphor="CircleDashed"
remixicon="RiRadioButtonLine"
size={size}
{...props}
/>
);
case "date":
return (
<IconPlaceholder
lucide="Calendar"
hugeicons="Calendar03Icon"
tabler="IconCalendar"
phosphor="Calendar"
remixicon="RiCalendarLine"
size={size}
{...props}
/>
);
case "tags":
return (
<IconPlaceholder
lucide="Tag"
hugeicons="Tag01Icon"
tabler="IconTag"
phosphor="Tag"
remixicon="RiPriceTagLine"
size={size}
{...props}
/>
);
case "group":
return (
<IconPlaceholder
lucide="Folder"
hugeicons="FolderIcon"
tabler="IconFolder"
phosphor="Folder"
remixicon="RiFolderLine"
size={size}
{...props}
/>
);
case "array":
return (
<IconPlaceholder
lucide="Layers"
hugeicons="Menu01Icon"
tabler="IconLayersIntersect"
phosphor="Stack"
remixicon="RiStackLine"
size={size}
{...props}
/>
);
case "row":
return (
<IconPlaceholder
lucide="Columns"
hugeicons="RowInsertIcon"
tabler="IconColumns"
phosphor="Columns"
remixicon="RiLayoutColumnLine"
size={size}
{...props}
/>
);
case "tabs":
return (
<IconPlaceholder
lucide="PanelTop"
hugeicons="Layout01Icon"
tabler="IconBrowser"
phosphor="Browser"
remixicon="RiWindowLine"
size={size}
{...props}
/>
);
case "collapsible":
return (
<IconPlaceholder
lucide="ChevronsDownUp"
hugeicons="ArrowShrink02Icon"
tabler="IconChevronDownLeft"
phosphor="CaretDoubleDown"
remixicon="RiArrowDownDoubleLine"
size={size}
{...props}
/>
);
case "upload":
return (
<IconPlaceholder
lucide="UploadCloud"
hugeicons="Upload01Icon"
tabler="IconUpload"
phosphor="UploadSimple"
remixicon="RiUploadCloud2Line"
size={size}
{...props}
/>
);
// Contextual icons
case "plus":
return (
<IconPlaceholder
lucide="Plus"
hugeicons="Add01Icon"
tabler="IconPlus"
phosphor="Plus"
remixicon="RiAddLine"
size={size}
{...props}
/>
);
case "move":
return (
<IconPlaceholder
lucide="Move"
hugeicons="Move01Icon"
tabler="IconArrowsMove"
phosphor="ArrowsOutCardinal"
remixicon="RiDragMove2Line"
size={size}
{...props}
/>
);
default:
return (
<IconPlaceholder
lucide="Circle"
hugeicons="CircleIcon"
tabler="IconCircle"
phosphor="Circle"
remixicon="RiCircleLine"
size={size}
{...props}
/>
);
}
};
registry/shadcn/builder/field-sidebar.tsx
"use client";
import React from "react";
import {
BuilderSidebar,
DraggableSidebarItem,
} from "@buildnbuzz/form-builder-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import { FieldIcon } from "./field-icon";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Visual field palette for the form builder.
* Grouped by category and draggable into the canvas.
*/
export const FieldSidebar = () => {
return (
<Sidebar className="h-full bg-background" collapsible="none">
<SidebarContent>
<BuilderSidebar
render={({ groups }) => (
<>
{Object.entries(groups).map(([category, items]) => (
<SidebarGroup key={category}>
<SidebarGroupLabel>
{category}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<DraggableSidebarItem
key={item.type}
type={item.type}
render={({
setNodeRef,
attributes,
listeners,
isDragging,
}) => {
const disabled = item.disabled;
return (
<SidebarMenuItem key={item.type}>
<SidebarMenuButton
ref={setNodeRef}
{...listeners}
{...attributes}
className={cn(
"cursor-grab group relative",
disabled && "cursor-not-allowed opacity-50",
isDragging && "opacity-40 bg-muted/50",
)}
// disabled={disabled} // DraggableSidebarItem handles disabled state
>
<FieldIcon type={item.type} size={16} />
<span>{item.label}</span>
{disabled && (
<Badge
variant="outline"
className="ml-auto text-[10px] h-5 px-1.5 py-0 bg-transparent text-muted-foreground border-muted-foreground/40"
>
Coming Soon
</Badge>
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
}}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
)}
/>
</SidebarContent>
</Sidebar>
);
};
registry/shadcn/builder/form-builder.tsx
"use client";
import React, { useEffect, useRef, useState } from "react";
import {
BuilderDndProvider,
BuilderCanvas as HeadlessCanvas,
useBuilderStore,
DefaultBuilderProvider,
BuilderFormProvider,
getBrowserLocalStorageProvider,
} from "@buildnbuzz/form-builder-react";
import { type BuilderStorageProvider } from "@buildnbuzz/form-builder-core";
import { SHADCN_BUILDER_REGISTRY } from "./registry";
import { SiteHeader } from "./header";
import { FieldSidebar } from "./field-sidebar";
import { NodeWrapper } from "./node-wrapper";
import { PropertyPanel } from "./property-panel";
import { DragOverlayItem } from "./drag-overlay";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { cn } from "@/lib/utils";
import { SidebarProvider } from "@/components/ui/sidebar";
import { CanvasToolbar } from "./canvas/canvas-toolbar";
import { MobileOverlay } from "./mobile-overlay";
import { toast } from "sonner";
import { IconPlaceholder } from "@/components/icon-placeholder";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
// Inline SubmitToastContent
function SubmitToastContent({ data }: { data: Record<string, unknown> }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative mt-2 w-full">
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="absolute right-2 top-2 h-7 w-7"
title="Copy JSON"
>
{copied ? (
<IconPlaceholder
lucide="Check"
hugeicons="Tick01Icon"
tabler="IconCheck"
phosphor="Check"
remixicon="RiCheckLine"
size={14}
className="text-primary"
/>
) : (
<IconPlaceholder
lucide="Copy"
hugeicons="Copy01Icon"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={14}
/>
)}
</Button>
<pre className="max-h-75 overflow-auto rounded-md bg-muted p-3 pt-6 text-xs text-muted-foreground sm:p-3 sm:pr-10 sm:pt-3">
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
</div>
);
}
const EmptyCanvas = () => {
return (
<Empty className="relative z-10 h-full border-0 bg-transparent min-h-60 pointer-events-none select-none">
<EmptyMedia>
<IconPlaceholder
lucide="MousePointerClick"
hugeicons="DragDropIcon"
tabler="IconHandClick"
phosphor="HandArrowUp"
remixicon="RiDragDropLine"
size={24}
/>
</EmptyMedia>
<EmptyContent className="max-w-[70%]">
<EmptyTitle className="text-sm font-medium">Empty Canvas</EmptyTitle>
<EmptyDescription className="text-xs">
Drag components from the left sidebar to start building your form.
</EmptyDescription>
</EmptyContent>
</Empty>
);
};
interface WindowFrameProps {
children: React.ReactNode;
className?: string;
}
export function WindowFrame({ children, className }: WindowFrameProps) {
const viewport = useBuilderStore((state) => state.viewport);
const zoom = useBuilderStore((state) => state.zoom);
const mode = useBuilderStore((state) => state.mode);
const getViewportWidth = () => {
switch (viewport) {
case "mobile":
return "375px";
case "tablet":
return "768px";
default:
return "100%";
}
};
return (
<div
className={cn(
"bg-background h-160 border border-border rounded-xl shadow-[0_0_60px_-5px_hsl(var(--primary)/0.4)] flex flex-col relative transition-all duration-300 ease-in-out origin-top overflow-hidden",
className,
)}
style={{
transform: `scale(${zoom})`,
width: getViewportWidth(),
maxWidth: viewport === "desktop" ? "896px" : getViewportWidth(),
}}
>
<div className="h-10 bg-muted/50 border-b flex items-center px-4 justify-between shrink-0">
<div className="flex items-center gap-1.5 w-20">
<div className="w-3 h-3 rounded-full bg-destructive/80" />
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
<div className="w-3 h-3 rounded-full bg-green-500/80" />
</div>
<div className="text-xs text-muted-foreground font-medium flex-1 text-center">
{mode === "preview" ? "Preview Mode" : "Edit Mode"}
</div>
<div className="w-20" />
</div>
{/* Content Area */}
<ScrollArea className="flex-1 overflow-hidden">{children}</ScrollArea>
</div>
);
}
import { PreviewForm } from "./preview/preview-form";
/**
* Centered frame for the canvas to simulate different viewports.
*/
const CanvasFrame = () => {
const rootIds = useBuilderStore((s) => s.rootIds);
const mode = useBuilderStore((s) => s.mode);
const selectNode = useBuilderStore((s) => s.selectNode);
const isPreviewMode = mode === "preview";
const { setNodeRef } = useDroppable({
id: "root",
disabled: isPreviewMode,
});
const handleBackgroundClick = () => {
if (!isPreviewMode) {
selectNode(null);
}
};
return (
<div
className="p-8 pt-10 flex justify-center items-start min-h-full"
onClick={handleBackgroundClick}
>
<WindowFrame>
<div
ref={isPreviewMode ? undefined : setNodeRef}
className="p-8 max-w-2xl mx-auto min-h-full relative"
data-id="root"
>
{rootIds.length === 0 ? (
<EmptyCanvas />
) : isPreviewMode ? (
<PreviewForm />
) : (
<div className="min-h-120 relative">
<SortableContext
items={rootIds}
strategy={verticalListSortingStrategy}
>
<HeadlessCanvas
nodeRenderer={({ node, content }) => (
<NodeWrapper node={node}>{content}</NodeWrapper>
)}
/>
</SortableContext>
</div>
)}
</div>
</WindowFrame>
</div>
);
};
/**
* Main Form Builder component that orchestrates the entire UI.
*/
const NEW_BUILDER_STORAGE_NAMESPACE = "buzzform-builder:v2:forms";
export const FormBuilder = () => {
const storageProvider = React.useMemo(() => {
if (typeof window === "undefined") return null;
try {
return getBrowserLocalStorageProvider(NEW_BUILDER_STORAGE_NAMESPACE);
} catch {
return null;
}
}, []);
return (
<DefaultBuilderProvider
registry={SHADCN_BUILDER_REGISTRY}
storageProvider={storageProvider}
>
<FormBuilderContent storageProvider={storageProvider} />
</DefaultBuilderProvider>
);
};
/**
* Inner component that uses builder hooks.
*/
const FormBuilderContent = ({
storageProvider,
}: {
storageProvider: BuilderStorageProvider | null;
}) => {
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
const containerRef = useRef<HTMLDivElement>(null);
const zoom = useBuilderStore((state) => state.zoom);
const setZoom = useBuilderStore((state) => state.setZoom);
const mode = useBuilderStore((s) => s.mode);
const onSubmit = async (data: Record<string, unknown>) => {
await new Promise((r) => setTimeout(r, 500));
toast("Form Submitted!", {
description: <SubmitToastContent data={data} />,
duration: 10000,
});
};
// Handle Ctrl+Wheel zoom
useEffect(() => {
if (!mounted) return;
const container = containerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey) {
e.preventDefault();
const delta = e.deltaY * -0.002;
const nextZoom = Math.round((zoom + delta) * 100) / 100;
// Clamp zoom between 0.25 and 2
const clampedZoom = Math.max(0.25, Math.min(2, nextZoom));
setZoom(clampedZoom);
}
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, [zoom, setZoom, mounted]);
if (!mounted) return null;
return (
<BuilderDndProvider
renderOverlay={({ activeId, activeData }) => (
<DragOverlayItem activeId={activeId} activeData={activeData} />
)}
>
<BuilderFormProvider mode={mode} onSubmit={onSubmit}>
<MobileOverlay />
<div className="flex flex-col h-screen w-full bg-muted/30 overflow-hidden text-foreground">
{/* Top Header */}
<SiteHeader storageProvider={storageProvider} />
{/* Main Layout */}
<SidebarProvider className="flex-1 overflow-hidden min-h-0 min-w-0">
<div className="flex h-full w-full">
{/* Left Sidebar: Components */}
<div
className={cn(
"border-r bg-background transition-all duration-300 ease-in-out",
mode === "preview"
? "w-0 opacity-0 overflow-hidden border-none"
: "w-64",
)}
>
<div className="w-64">
<FieldSidebar />
</div>
</div>
{/* Central Canvas */}
<main className="flex-1 flex flex-col relative overflow-hidden bg-muted/20 min-w-0">
<CanvasToolbar />
<div ref={containerRef} className="flex-1 min-h-0">
<div className="h-full overflow-auto [&::-webkit-scrollbar]:hidden [scrollbar-width:none] bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#1f2937_1px,transparent_1px)] bg-size-[20px_20px]">
<CanvasFrame />
</div>
</div>
</main>
{/* Right Sidebar: Properties */}
<aside
className={cn(
"border-l bg-background flex flex-col shrink-0 transition-all duration-300 ease-in-out",
mode === "preview"
? "w-0 opacity-0 overflow-hidden border-none"
: "w-80",
)}
>
<div className="w-80 h-full flex flex-col">
<PropertyPanel />
</div>
</aside>
</div>
</SidebarProvider>
</div>
</BuilderFormProvider>
</BuilderDndProvider>
);
};
registry/shadcn/builder/header/cloud-save-dialog.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import { HugeiconsIcon } from "@hugeicons/react";
import { CloudServerIcon } from "@hugeicons/core-free-icons";
import { Sparkles } from "lucide-react";
import { joinWaitlist } from "@/app/(builder)/lib/actions";
import { toast } from "sonner";
import { createSchema, InferType } from "@buildnbuzz/buzzform";
import {
Form,
FormContent,
FormFields,
FormSubmit,
FormAction,
} from "@/registry/base/form";
import Link from "next/link";
const waitlistSchema = createSchema([
{
type: "email",
name: "email",
label: "Email Address",
placeholder: "you@example.com",
required: true,
description: (
<span className="text-xs">
By joining, you agree to our{" "}
<Link
href="/terms"
className="underline hover:text-primary underline-offset-4"
>
Terms
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="underline hover:text-primary underline-offset-4"
>
Privacy Policy
</Link>
.
</span>
),
},
]);
export type WaitlistData = InferType<typeof waitlistSchema>;
export function CloudSaveDialog() {
const [open, setOpen] = useState(false);
const handleSubmit = async (data: WaitlistData) => {
try {
const result = await joinWaitlist(data.email);
if (result.error) {
toast.error(result.error);
} else {
toast.success(result.message || "You've been added to the waitlist!");
setOpen(false);
}
} catch {
toast.error("Something went wrong. Please try again.");
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button
variant="outline"
size="sm"
className="gap-2 h-8 hidden sm:flex"
>
<HugeiconsIcon icon={CloudServerIcon} size={16} />
Save
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HugeiconsIcon
icon={CloudServerIcon}
className="text-primary h-6 w-6"
/>
Save & Sync
</DialogTitle>
<DialogDescription>
Never lose your work again. Save your forms to the cloud, access
version history, and create your central form repository.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col">
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3 text-sm flex gap-3 text-primary mb-6">
<Sparkles className="h-5 w-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Coming Soon</p>
<p className="text-muted-foreground text-xs">
We're currently building the persistence layer. Join the
waitlist to get <b>early access</b> and{" "}
<b>free cloud storage</b> when we launch.
</p>
</div>
</div>
<Form schema={waitlistSchema} onSubmit={handleSubmit}>
<FormContent className="space-y-4">
<FormFields />
<DialogFooter>
<FormAction
variant="ghost"
onClick={() => setOpen(false)}
type="button"
>
Cancel
</FormAction>
<FormSubmit>Join Waitlist</FormSubmit>
</DialogFooter>
</FormContent>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
registry/shadcn/builder/header/form-manager-dialog.tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { FormManagerNewPanel } from "./form-manager-new-panel";
import { FormManagerSavedPanel } from "./form-manager-saved-panel";
import type { BuilderStorageProvider } from "@buildnbuzz/form-builder-core";
export function FormManagerDialog({
storageProvider,
}: {
storageProvider: BuilderStorageProvider | null;
}) {
const [open, setOpen] = React.useState(false);
const handleDone = React.useCallback(() => setOpen(false), []);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button className="group gap-2">
<IconPlaceholder
hugeicons="Add01Icon"
lucide="Plus"
tabler="IconPlus"
phosphor="Plus"
remixicon="RiAddLine"
size={16}
strokeWidth={2}
className="transition-transform duration-300 group-hover:rotate-90"
/>
New
</Button>
}
/>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Forms</DialogTitle>
<DialogDescription>
Create a new form, import a file, or open a previously saved form.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="new">
<div className="flex justify-center">
<TabsList className="grid w-full max-w-xs grid-cols-2 rounded-full bg-muted">
<TabsTrigger value="new" className="gap-2 rounded-full">
<IconPlaceholder
hugeicons="Add01Icon"
lucide="Plus"
tabler="IconPlus"
phosphor="Plus"
remixicon="RiAddLine"
size={16}
strokeWidth={2}
/>
New
</TabsTrigger>
<TabsTrigger value="saved" className="gap-2 rounded-full">
<IconPlaceholder
hugeicons="FolderIcon"
lucide="Folder"
tabler="IconFolder"
phosphor="Folder"
remixicon="RiFolderLine"
size={16}
strokeWidth={2}
/>
Saved Forms
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="new">
<FormManagerNewPanel onDone={handleDone} />
</TabsContent>
<TabsContent value="saved">
<FormManagerSavedPanel
onDone={handleDone}
storageProvider={storageProvider}
/>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
registry/shadcn/builder/header/form-manager-new-panel.tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { useBuilderStore } from "@buildnbuzz/form-builder-react";
import { toast } from "sonner";
import {
parseImportedFormJson,
type ParsedImportPayload,
} from "@buildnbuzz/form-builder-core";
const MIN_IMPORT_FEEDBACK_MS = 500;
type PendingImport = {
sourceName: string;
formName: string;
nodeCount: number;
state: ParsedImportPayload["state"];
warnings?: string[];
};
type FormManagerNewPanelProps = {
onDone: () => void;
};
type FormManagerPendingImportStateProps = {
pendingImport: PendingImport;
isImporting: boolean;
onConfirmImport: () => void;
onClearSelection: () => void;
};
type FormManagerPasteImportStateProps = {
value: string;
isImporting: boolean;
onChangeValue: (value: string) => void;
onBack: () => void;
onPasteFromClipboard: () => void;
onImport: () => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
};
function FormManagerPendingImportState({
pendingImport,
isImporting,
onConfirmImport,
onClearSelection,
}: FormManagerPendingImportStateProps) {
return (
<div className="flex w-full flex-1 flex-col animate-in fade-in duration-200">
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-8">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
<IconPlaceholder
hugeicons="File02Icon"
lucide="File"
tabler="IconFile"
phosphor="File"
remixicon="RiFileLine"
size={32}
strokeWidth={1.5}
/>
</div>
<div className="space-y-2 text-center">
<h3 className="text-xl font-semibold tracking-tight">
{pendingImport.formName}
</h3>
<p className="text-sm text-muted-foreground">
{pendingImport.sourceName}
</p>
<div className="flex items-center justify-center gap-2">
<span className="text-xs text-muted-foreground">
{pendingImport.nodeCount} nodes
</span>
</div>
</div>
</div>
<div className="mt-auto flex w-full flex-col gap-4 rounded-xl border bg-muted/30 p-4">
{pendingImport.warnings && pendingImport.warnings.length > 0 && (
<Alert className="border-amber-500/30 bg-amber-500/10 text-amber-600 dark:border-amber-500/20 dark:text-amber-400">
<div className="flex items-center gap-2">
<IconPlaceholder
hugeicons="Alert01Icon"
lucide="AlertTriangle"
tabler="IconAlertTriangle"
phosphor="Warning"
remixicon="RiAlertFill"
size={16}
/>
<AlertTitle className="m-0 text-sm font-semibold">
Legacy Schema Migrated
</AlertTitle>
</div>
<AlertDescription className="mt-2 max-h-32 overflow-y-auto">
<ul className="ml-5 list-disc space-y-1 text-xs opacity-90">
{pendingImport.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<div className="flex items-start gap-3">
<div className="mt-0.5 text-amber-600 dark:text-amber-500">
<IconPlaceholder
hugeicons="Alert01Icon"
lucide="AlertCircle"
tabler="IconAlertCircle"
phosphor="WarningCircle"
remixicon="RiAlertLine"
size={18}
strokeWidth={2}
/>
</div>
<div className="flex-1 space-y-0.5">
<p className="text-sm font-medium leading-none">
Replace current workspace?
</p>
<p className="text-xs text-muted-foreground">
Continuing will overwrite any unsaved changes.
</p>
</div>
</div>
<div className="mt-1 grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={onClearSelection}
disabled={isImporting}
>
Cancel
</Button>
<Button onClick={onConfirmImport} disabled={isImporting}>
<IconPlaceholder
hugeicons="Upload01Icon"
lucide="Upload"
tabler="IconUpload"
phosphor="UploadSimple"
remixicon="RiUpload2Line"
size={16}
className="mr-2"
strokeWidth={2}
/>
Import Now
</Button>
</div>
</div>
</div>
);
}
function FormManagerPasteImportState({
value,
isImporting,
onChangeValue,
onBack,
onPasteFromClipboard,
onImport,
onDragOver,
onDrop,
}: FormManagerPasteImportStateProps) {
return (
<div className="flex min-h-0 w-full flex-1 flex-col gap-3 animate-in fade-in duration-200">
<div className="space-y-1">
<h3 className="text-lg font-semibold tracking-tight">Import JSON</h3>
<p className="text-sm text-muted-foreground">
Paste a BuzzForm schema or Builder backup JSON.
</p>
</div>
<div
className="h-56 min-h-0 overflow-hidden rounded-lg border border-dashed border-border/70 p-2 transition-colors hover:border-primary/50"
onDragOver={onDragOver}
onDrop={onDrop}
>
<Textarea
value={value}
onChange={(event) => onChangeValue(event.target.value)}
placeholder='[\n { "type": "text", "name": "email", "label": "Email" }\n]'
wrap="off"
className="h-full min-h-0 resize-none overflow-x-auto overflow-y-auto border-0 bg-transparent font-mono text-xs whitespace-pre shadow-none field-sizing-fixed focus-visible:ring-0"
/>
</div>
<div className="mt-auto flex flex-wrap justify-end gap-2">
<Button variant="ghost" onClick={onBack} disabled={isImporting}>
Back
</Button>
<Button
variant="outline"
onClick={onPasteFromClipboard}
disabled={isImporting}
>
<IconPlaceholder
hugeicons="Copy01Icon"
lucide="Copy"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={16}
className="mr-2"
strokeWidth={2}
/>
Paste from Clipboard
</Button>
<Button
onClick={onImport}
disabled={isImporting || value.trim().length === 0}
>
{isImporting ? (
<Spinner className="mr-2 size-4" />
) : (
<IconPlaceholder
hugeicons="Upload01Icon"
lucide="Upload"
tabler="IconUpload"
phosphor="UploadSimple"
remixicon="RiUpload2Line"
size={16}
className="mr-2"
strokeWidth={2}
/>
)}
Validate JSON
</Button>
</div>
</div>
);
}
export function FormManagerNewPanel({ onDone }: FormManagerNewPanelProps) {
const [isImporting, setIsImporting] = React.useState(false);
const [pendingImport, setPendingImport] =
React.useState<PendingImport | null>(null);
const [importError, setImportError] = React.useState<string | null>(null);
const [isPasteMode, setIsPasteMode] = React.useState(false);
const [pastedJson, setPastedJson] = React.useState("");
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const clearState = useBuilderStore((state) => state.clearState);
const loadDocumentState = useBuilderStore((state) => state.loadDocumentState);
const resetImportState = React.useCallback(() => {
setIsImporting(false);
setPendingImport(null);
setImportError(null);
}, []);
const handleNewForm = () => {
clearState();
onDone();
};
const handleConfirmImport = () => {
if (!pendingImport) return;
loadDocumentState(
pendingImport.state as Parameters<typeof loadDocumentState>[0],
);
toast.success(`Imported "${pendingImport.formName}"`);
onDone();
};
const handleImportClick = () => {
if (isImporting) return;
fileInputRef.current?.click();
};
const handleOpenPasteMode = () => {
if (isImporting) return;
setImportError(null);
setIsPasteMode(true);
};
const handleClosePasteMode = () => {
if (isImporting) return;
setImportError(null);
setIsPasteMode(false);
};
const processImportJson = React.useCallback(
async ({
json,
sourceName,
schemaFileNameHint,
closePasteModeOnSuccess,
}: {
json: string;
sourceName: string;
schemaFileNameHint?: string;
closePasteModeOnSuccess: boolean;
}) => {
const startedAt = Date.now();
setImportError(null);
setPendingImport(null);
setIsImporting(true);
try {
const parsed = parseImportedFormJson(json);
const importedState =
schemaFileNameHint && parsed.state.formName === "Imported Form"
? {
...parsed.state,
formName: schemaFileNameHint,
}
: parsed.state;
setPendingImport({
sourceName,
formName: importedState.formName,
nodeCount: Object.keys(importedState.nodes).length,
state: importedState,
warnings: parsed.warnings,
});
if (closePasteModeOnSuccess) {
setIsPasteMode(false);
}
} catch (error) {
const rawMessage =
error instanceof Error ? error.message : "Failed to import JSON.";
const message = `Invalid import JSON: ${rawMessage}`;
setImportError(message);
toast.error(message);
} finally {
const elapsed = Date.now() - startedAt;
if (elapsed < MIN_IMPORT_FEEDBACK_MS) {
await new Promise((resolve) =>
setTimeout(resolve, MIN_IMPORT_FEEDBACK_MS - elapsed),
);
}
setIsImporting(false);
}
},
[],
);
const handleFileImport = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const contents = await file.text();
await processImportJson({
json: contents,
sourceName: file.name,
schemaFileNameHint: toFormNameHint(file.name),
closePasteModeOnSuccess: false,
});
};
const handleImportPastedJson = async () => {
const trimmedJson = pastedJson.trim();
if (!trimmedJson) {
const message = "Paste JSON content before importing.";
setImportError(message);
toast.error(message);
return;
}
await processImportJson({
json: trimmedJson,
sourceName: "Pasted JSON",
closePasteModeOnSuccess: true,
});
};
const handlePasteFromClipboard = async () => {
if (typeof navigator === "undefined" || !navigator.clipboard?.readText) {
toast.error("Clipboard access is not available. Paste manually.");
return;
}
try {
const clipboardText = await navigator.clipboard.readText();
if (!clipboardText.trim()) {
toast.error("Clipboard is empty.");
return;
}
setPastedJson(clipboardText);
setImportError(null);
toast.success("Pasted JSON from clipboard");
} catch {
toast.error("Clipboard access was denied. Paste JSON manually.");
}
};
const handlePasteDropZoneDragOver = (
event: React.DragEvent<HTMLDivElement>,
) => {
event.preventDefault();
};
const handlePasteDropZoneDrop = async (
event: React.DragEvent<HTMLDivElement>,
) => {
event.preventDefault();
if (isImporting) return;
const file = event.dataTransfer.files?.[0];
if (file) {
try {
const droppedContents = await file.text();
if (!droppedContents.trim()) {
toast.error("Dropped file is empty.");
return;
}
setPastedJson(droppedContents);
setImportError(null);
toast.success(`Loaded JSON from "${file.name}"`);
} catch {
toast.error("Failed to read dropped file.");
}
return;
}
const droppedText = event.dataTransfer.getData("text/plain");
if (!droppedText.trim()) {
toast.error("Dropped content is empty.");
return;
}
setPastedJson(droppedText);
setImportError(null);
toast.success("Loaded dropped text");
};
React.useEffect(() => resetImportState, [resetImportState]);
const hasPendingImport =
pendingImport !== null && !isImporting && !importError;
return (
<div className="flex min-h-80 flex-col gap-3">
<div className="flex flex-1 p-4">
{hasPendingImport ? (
<FormManagerPendingImportState
pendingImport={pendingImport}
isImporting={isImporting}
onConfirmImport={handleConfirmImport}
onClearSelection={resetImportState}
/>
) : isPasteMode ? (
<FormManagerPasteImportState
value={pastedJson}
isImporting={isImporting}
onChangeValue={setPastedJson}
onBack={handleClosePasteMode}
onPasteFromClipboard={handlePasteFromClipboard}
onImport={handleImportPastedJson}
onDragOver={handlePasteDropZoneDragOver}
onDrop={handlePasteDropZoneDrop}
/>
) : (
<div className="grid w-full flex-1 gap-4 sm:grid-cols-3">
<Card
className="group flex cursor-pointer flex-col justify-center transition-all duration-200 hover:ring-1 hover:ring-primary/50"
onClick={handleNewForm}
>
<CardContent className="flex justify-center pb-2 pt-6">
<IconPlaceholder
hugeicons="Add01Icon"
lucide="Plus"
tabler="IconPlus"
phosphor="Plus"
remixicon="RiAddLine"
size={32}
strokeWidth={1.5}
className="text-muted-foreground transition-all duration-300 group-hover:scale-110 group-hover:rotate-90 group-hover:text-primary"
/>
</CardContent>
<CardHeader className="justify-center text-center">
<CardTitle className="text-lg">Start Blank</CardTitle>
<CardDescription>Create a fresh empty form</CardDescription>
</CardHeader>
</Card>
<Card
className="group flex cursor-pointer flex-col justify-center transition-all duration-200 hover:ring-1 hover:ring-primary/50"
onClick={handleImportClick}
>
<CardContent className="flex justify-center pb-2 pt-6">
{isImporting ? (
<div className="flex items-center justify-center p-2 text-muted-foreground transition-colors group-hover:text-primary">
<Spinner className="size-10" />
</div>
) : (
<IconPlaceholder
hugeicons="Upload01Icon"
lucide="Upload"
tabler="IconUpload"
phosphor="UploadSimple"
remixicon="RiUpload2Line"
size={32}
strokeWidth={1.5}
className="text-muted-foreground transition-all duration-300 group-hover:-translate-y-1 group-hover:scale-110 group-hover:text-primary"
/>
)}
</CardContent>
<CardHeader className="justify-center text-center">
<CardTitle className="text-lg">
{isImporting ? "Validating File" : "Import File"}
</CardTitle>
<CardDescription>
{isImporting
? "Checking JSON before import"
: "Load .json from disk"}
</CardDescription>
</CardHeader>
</Card>
<Card
className="group flex cursor-pointer flex-col justify-center transition-all duration-200 hover:ring-1 hover:ring-primary/50"
onClick={handleOpenPasteMode}
>
<CardContent className="flex justify-center pb-2 pt-6">
<IconPlaceholder
hugeicons="Copy01Icon"
lucide="Copy"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={32}
strokeWidth={1.5}
className="text-muted-foreground transition-all duration-300 group-hover:-translate-y-1 group-hover:scale-110 group-hover:text-primary"
/>
</CardContent>
<CardHeader className="justify-center text-center">
<CardTitle className="text-lg">Import JSON</CardTitle>
<CardDescription>Paste schema from clipboard</CardDescription>
</CardHeader>
</Card>
</div>
)}
</div>
{importError && (
<Alert variant="destructive">
<IconPlaceholder
hugeicons="Alert01Icon"
lucide="AlertCircle"
tabler="IconAlertCircle"
phosphor="WarningCircle"
remixicon="RiAlertLine"
size={16}
/>
<AlertTitle>Import Failed</AlertTitle>
<AlertDescription>{importError}</AlertDescription>
</Alert>
)}
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={handleFileImport}
/>
</div>
);
}
function toFormNameHint(sourceName: string): string | undefined {
const normalizedBase = sourceName
.replace(/\.[^/.]+$/, "")
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (normalizedBase.length === 0) {
return undefined;
}
return normalizedBase
.split(" ")
.map((segment) => {
if (segment.length === 0) return segment;
return segment[0]!.toUpperCase() + segment.slice(1).toLowerCase();
})
.join(" ");
}
registry/shadcn/builder/header/form-manager-saved-panel.tsx
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { IconPlaceholder } from "@/components/icon-placeholder";
import type {
FormSummary,
BuilderStorageProvider,
} from "@buildnbuzz/form-builder-core";
import { toSafeFileName, fieldsToBuilderState } from "@buildnbuzz/form-builder-core";
import { useBuilderStore, downloadTextFile } from "@buildnbuzz/form-builder-react";
import type { Field } from "@buildnbuzz/form-core";
import { toast } from "sonner";
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
type FormManagerSavedPanelProps = {
onDone: () => void;
storageProvider: BuilderStorageProvider | null;
};
export function FormManagerSavedPanel({
onDone,
storageProvider,
}: FormManagerSavedPanelProps) {
const [forms, setForms] = React.useState<FormSummary[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [openingFormId, setOpeningFormId] = React.useState<string | null>(null);
const [removingFormId, setRemovingFormId] = React.useState<string | null>(
null,
);
const [downloadingFormId, setDownloadingFormId] = React.useState<
string | null
>(null);
const loadDocumentState = useBuilderStore((state) => state.loadDocumentState);
const currentFormId = useBuilderStore((state) => state.formId);
const loadForms = React.useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
if (!storageProvider) return;
const items = await storageProvider.list();
setForms(items);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load forms.";
setLoadError(message);
} finally {
setIsLoading(false);
}
}, [storageProvider]);
React.useEffect(() => {
void loadForms();
}, [loadForms]);
const handleOpenForm = async (formId: string) => {
setOpeningFormId(formId);
try {
if (!storageProvider) return;
const document = await storageProvider.load(formId);
if (!document) throw new Error("Document not found");
const { nodes, rootIds } = fieldsToBuilderState(
document.fields as readonly Field[],
);
loadDocumentState({
nodes,
rootIds,
formId: document.id || formId,
formName: document.title || "Untitled",
});
onDone();
toast.success(`Opened "${document.title || "Untitled"}"`);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to open form.";
toast.error(message);
} finally {
setOpeningFormId(null);
}
};
const handleRemoveForm = async (form: FormSummary) => {
if (
!window.confirm(
`Remove "${form.formName}" from local saved forms? This does not affect the currently open editor until you switch forms.`,
)
) {
return;
}
setRemovingFormId(form.formId);
try {
if (!storageProvider) return;
await storageProvider.remove(form.formId);
setForms((prev) => prev.filter((item) => item.formId !== form.formId));
toast.success(`Removed "${form.formName}"`);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to remove form.";
toast.error(message);
} finally {
setRemovingFormId(null);
}
};
const handleDownloadForm = async (form: FormSummary) => {
setDownloadingFormId(form.formId);
try {
if (!storageProvider) return;
const document = await storageProvider.load(form.formId);
if (!document) throw new Error("Document not found");
const fileName = `${toSafeFileName(document.title || "Untitled")}.json`;
downloadTextFile(
JSON.stringify(document, null, 2),
fileName,
"application/json",
);
toast.success(`Downloaded "${document.title || "Untitled"}"`);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to download form.";
toast.error(message);
} finally {
setDownloadingFormId(null);
}
};
return (
<div className="flex min-h-80 flex-col gap-3">
{isLoading ? (
<div className="flex h-64 items-center justify-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" />
Loading saved forms...
</div>
) : loadError ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<p className="text-sm text-destructive">{loadError}</p>
<Button type="button" variant="outline" size="sm" onClick={loadForms}>
Retry
</Button>
</div>
) : forms.length === 0 ? (
<Empty className="min-h-64">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconPlaceholder
hugeicons="FolderIcon"
lucide="Folder"
tabler="IconFolder"
phosphor="Folder"
remixicon="RiFolderLine"
size={16}
strokeWidth={2}
/>
</EmptyMedia>
<EmptyTitle>No saved forms yet</EmptyTitle>
<EmptyDescription>
Forms appear here after you edit and save them locally.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<>
<div className="rounded-lg border">
<ScrollArea className="h-72 w-full">
<div className="space-y-2 p-2">
{forms.map((form) => {
const isOpening = openingFormId === form.formId;
const isRemoving = removingFormId === form.formId;
const isDownloading = downloadingFormId === form.formId;
const isBusy = isOpening || isRemoving || isDownloading;
const isCurrent = currentFormId === form.formId;
return (
<div
key={form.formId}
className="flex items-start gap-3 rounded-md border p-3"
>
<div className="mt-0.5 rounded-md border bg-muted p-1.5">
<IconPlaceholder
hugeicons="InputShortTextIcon"
lucide="Type"
tabler="IconLetterT"
phosphor="TextT"
remixicon="RiText"
size={14}
strokeWidth={2}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{form.formName}
</p>
{isCurrent && (
<Badge variant="secondary">Current</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<IconPlaceholder
hugeicons="Clock01Icon"
lucide="Clock"
tabler="IconClock"
phosphor="Clock"
remixicon="RiTimeLine"
size={12}
strokeWidth={2}
/>
<span>{dateFormatter.format(form.updatedAt)}</span>
</div>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={isBusy}
onClick={() => handleOpenForm(form.formId)}
>
{isOpening ? (
<Spinner className="size-3.5" />
) : (
<IconPlaceholder
hugeicons="ArrowRight01Icon"
lucide="ArrowRight"
tabler="IconArrowRight"
phosphor="ArrowRight"
remixicon="RiArrowRightLine"
size={14}
strokeWidth={2}
/>
)}
Open
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={isBusy}
onClick={() => handleDownloadForm(form)}
className="text-muted-foreground"
>
{isDownloading ? (
<Spinner className="size-3.5" />
) : (
<IconPlaceholder
hugeicons="Download02Icon"
lucide="Download"
tabler="IconDownload"
phosphor="DownloadSimple"
remixicon="RiDownload2Line"
size={14}
strokeWidth={2}
/>
)}
<span className="sr-only">
Download saved form
</span>
</Button>
}
/>
<TooltipContent side="top">Download</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={isBusy}
onClick={() => handleRemoveForm(form)}
className="text-muted-foreground hover:text-destructive"
>
{isRemoving ? (
<Spinner className="size-3.5" />
) : (
<IconPlaceholder
hugeicons="Delete02Icon"
lucide="Trash2"
tabler="IconTrash"
phosphor="Trash"
remixicon="RiDeleteBinLine"
size={14}
strokeWidth={2}
/>
)}
<span className="sr-only">
Remove saved form
</span>
</Button>
}
/>
<TooltipContent side="top">Delete</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
<div className="flex justify-start">
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={loadForms}
>
<IconPlaceholder
hugeicons="Refresh01Icon"
lucide="RefreshCw"
tabler="IconRefresh"
phosphor="ArrowsClockwise"
remixicon="RiRefreshLine"
size={14}
strokeWidth={2}
/>
Refresh
</Button>
</div>
</>
)}
</div>
);
}
registry/shadcn/builder/header/index.tsx
"use client";
import Image from "next/image";
import Link from "next/link";
import { ThemeToggle } from "@/components/theme-toggle";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { useBuilderStore } from "@buildnbuzz/form-builder-react";
import { SaveIndicator } from "./save-indicator";
import { FormManagerDialog } from "./form-manager-dialog";
import { CloudSaveDialog } from "./cloud-save-dialog";
import { ExportSheet } from "../export-sheet";
import type { BuilderStorageProvider } from "@buildnbuzz/form-builder-core";
export function SiteHeader({
storageProvider,
}: {
storageProvider: BuilderStorageProvider | null;
}) {
const hasContent = useBuilderStore((state) => state.rootIds.length > 0);
return (
<header className="bg-background sticky top-0 z-50 flex w-full items-center border-b">
<div className="flex h-header w-full items-center gap-2 px-4 text-foreground">
<div className="flex min-w-0 items-center gap-2">
<Link href="/" className="flex items-center gap-2">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg invert dark:invert-0">
<Image src="/bb-icon.svg" alt="Logo" width={24} height={24} />
</div>
<span className="text-sm font-semibold tracking-tight">
BuzzForm
</span>
<Badge
variant="secondary"
className="h-5 rounded-full px-2 text-[10px] font-semibold tracking-wide"
>
Preview
</Badge>
</Link>
</div>
<div className="ml-auto flex items-center gap-4">
<SaveIndicator />
{hasContent && (
<Separator
orientation="vertical"
className="h-5 data-[orientation=vertical]:self-center"
/>
)}
<div className="flex items-center gap-2">
<ThemeToggle />
<CloudSaveDialog />
<ExportSheet />
<FormManagerDialog storageProvider={storageProvider} />
</div>
</div>
</div>
</header>
);
}
registry/shadcn/builder/header/save-indicator.tsx
"use client";
import { useBuilderStore } from "@buildnbuzz/form-builder-react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
}
export function SaveIndicator() {
const saveStatus = useBuilderStore((state) => state.saveStatus);
const lastSavedAt = useBuilderStore((state) => state.lastSavedAt);
const hasContent = useBuilderStore((state) => state.rootIds.length > 0);
if (!hasContent) return null;
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs text-muted-foreground transition-opacity duration-200",
saveStatus === "idle" && !lastSavedAt && "opacity-0",
)}
>
{saveStatus === "saving" ? (
<>
<IconPlaceholder
hugeicons="Loading03Icon"
lucide="Loader2"
tabler="IconLoader2"
phosphor="CircleNotch"
remixicon="RiLoader2Line"
size={14}
className="animate-spin"
/>
<span>Saving...</span>
</>
) : lastSavedAt ? (
<>
<IconPlaceholder
hugeicons="CheckmarkCircle01Icon"
lucide="CheckCircle2"
tabler="IconCircleCheck"
phosphor="CheckCircle"
remixicon="RiCheckboxCircleLine"
size={14}
className="text-emerald-500"
/>
<span>Last saved at {formatTime(lastSavedAt)}</span>
</>
) : null}
</div>
);
}
registry/shadcn/builder/layout/array-renderer.tsx
"use client";
import { useEffect, useRef } from "react";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { IconPlaceholder } from "@/components/icon-placeholder";
import {
DEFAULT_SLOT,
useBuilderStore,
type BuilderNodeRendererProps,
} from "@buildnbuzz/form-builder-react";
import type { ArrayField } from "@buildnbuzz/form-core";
/**
* Visual renderer for 'array' layout fields.
* Wraps repeating items with min/max indicator limits securely resolving DnD sorting wrappers context.
*/
export const ArrayRenderer = ({
id,
field,
childrenIds,
renderSlot,
}: BuilderNodeRendererProps) => {
const arrayField = field as ArrayField;
const rawField = arrayField as unknown as Record<string, unknown>;
const uiConfig = (arrayField.ui as Record<string, unknown> | undefined) ?? {};
const defaultCollapsed = (uiConfig.collapsed as boolean | undefined) ?? false;
const minItems = arrayField.minItems;
const maxItems = arrayField.maxItems;
const isCollapsed = useBuilderStore(
(s) => s.collapsedNodes[id] ?? defaultCollapsed,
);
const toggleCollapsed = useBuilderStore((s) => s.toggleCollapsed);
const setCollapsed = useBuilderStore((s) => s.setCollapsed);
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
if (defaultCollapsed) {
setCollapsed(id, true);
}
}
}, [id, defaultCollapsed, setCollapsed]);
const { setNodeRef, isOver } = useDroppable({
id: `${id}-dropzone`,
data: { type: "array", parentId: id, parentSlot: DEFAULT_SLOT },
});
const isEmpty = childrenIds.length === 0;
const renderDropZone = () => (
<div
ref={setNodeRef}
data-container-padding
className={cn("min-h-16 transition-colors", isOver && "bg-primary/5")}
>
{isEmpty ? (
<div
className={cn(
"h-16 flex items-center justify-center rounded-lg border-2 border-dashed transition-colors",
isOver
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/20 bg-muted/30",
)}
>
<span className="text-muted-foreground text-sm italic">
Drop fields for each array item
</span>
</div>
) : (
<div className="space-y-3">{renderSlot(DEFAULT_SLOT)}</div>
)}
</div>
);
return (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleCollapsed(id)}>
<Card className="w-full py-0 gap-0 border-dashed">
<CardHeader className="p-0 border-b-0">
<CollapsibleTrigger
className={cn(
"w-full px-4 py-3 flex flex-row items-center justify-between",
"hover:bg-muted/75 bg-muted/50 transition-colors select-none cursor-pointer",
!isCollapsed && "border-b",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<IconPlaceholder
lucide="Menu"
hugeicons="Menu01Icon"
size={16}
className="text-muted-foreground shrink-0"
/>
<span className="text-sm font-semibold truncate">
{(rawField.label as string) || "Array"}
</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{childrenIds.length}{" "}
{childrenIds.length === 1 ? "field" : "fields"}
</Badge>
{typeof minItems === "number" && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] text-muted-foreground"
>
min {minItems}
</Badge>
)}
{typeof maxItems === "number" && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] text-muted-foreground"
>
max {maxItems}
</Badge>
)}
</div>
<IconPlaceholder
lucide="ChevronDown"
hugeicons="ArrowDown01Icon"
size={16}
className={cn(
"text-muted-foreground transition-transform duration-200 shrink-0",
isCollapsed && "-rotate-90",
)}
/>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="p-4">
{!!rawField.description && (
<p className="text-sm text-muted-foreground mb-3">
{rawField.description as string}
</p>
)}
{renderDropZone()}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
};
registry/shadcn/builder/layout/collapsible-renderer.tsx
"use client";
import { useEffect, useRef } from "react";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { IconPlaceholder } from "@/components/icon-placeholder";
import {
DEFAULT_SLOT,
useBuilderStore,
type BuilderNodeRendererProps,
} from "@buildnbuzz/form-builder-react";
const spacingMap = {
sm: "space-y-2",
md: "space-y-3",
lg: "space-y-4",
};
/**
* Visual renderer for 'collapsible' container fields.
* Includes multiple UI variants natively integrated with builder store state tracking.
*/
export const CollapsibleRenderer = ({
id,
field,
childrenIds,
renderSlot,
}: BuilderNodeRendererProps) => {
const rawField = field as unknown as Record<string, unknown>;
const uiConfig = (rawField.ui as Record<string, unknown> | undefined) ?? {};
const label = rawField.label as string | undefined;
const variant =
(uiConfig.variant as "ghost" | "bordered" | "card" | "flat" | undefined) ??
"bordered";
const spacingConfig = uiConfig.spacing as string | undefined;
const spacingExtracted =
spacingConfig && spacingConfig in spacingMap
? (spacingConfig as keyof typeof spacingMap)
: "md";
const defaultCollapsed = (rawField.collapsed as boolean | undefined) ?? false;
const isCollapsed = useBuilderStore(
(s) => s.collapsedNodes[id] ?? defaultCollapsed,
);
const toggleCollapsed = useBuilderStore((s) => s.toggleCollapsed);
const setCollapsed = useBuilderStore((s) => s.setCollapsed);
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
if (defaultCollapsed) {
setCollapsed(id, true);
}
}
}, [id, defaultCollapsed, setCollapsed]);
const { setNodeRef, isOver } = useDroppable({
id: `${id}-dropzone`,
data: { type: "collapsible", parentId: id, parentSlot: DEFAULT_SLOT },
});
const isEmpty = childrenIds.length === 0;
const renderDropZone = () => (
<div
ref={setNodeRef}
data-container-padding
className={cn("min-h-16 transition-colors", isOver && "bg-primary/5")}
>
{isEmpty ? (
<div
className={cn(
"h-16 flex items-center justify-center rounded-lg border-2 border-dashed transition-colors",
isOver
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/20 bg-muted/30",
)}
>
<span className="text-muted-foreground text-sm italic">
Drop fields here
</span>
</div>
) : (
<div className={cn(spacingMap[spacingExtracted])}>
{renderSlot(DEFAULT_SLOT)}
</div>
)}
</div>
);
const renderHeaderContent = () => (
<div className="flex-1 min-w-0 flex items-center gap-2">
<IconPlaceholder
lucide="FoldVertical"
hugeicons="ArrowShrink02Icon"
size={16}
className="text-muted-foreground shrink-0"
/>
<span
className={cn(
"text-sm truncate",
variant === "card" ? "font-semibold" : "font-medium",
)}
>
{label || "Collapsible"}
</span>
{!isEmpty && (
<span className="text-xs text-muted-foreground">
({childrenIds.length} {childrenIds.length === 1 ? "field" : "fields"})
</span>
)}
</div>
);
const renderChevron = (size: number = 16) => (
<IconPlaceholder
lucide="ChevronDown"
hugeicons="ArrowDown01Icon"
size={size}
className={cn(
"text-muted-foreground transition-transform duration-200 shrink-0",
isCollapsed && "-rotate-90",
)}
/>
);
// === GHOST VARIANT ===
if (variant === "ghost") {
return (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleCollapsed(id)}>
<div className="w-full">
<CollapsibleTrigger className="w-full px-2 py-2 rounded-md flex flex-row items-center justify-between hover:bg-muted/50 transition-colors select-none cursor-pointer">
{renderHeaderContent()}
{renderChevron(14)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pt-3 pl-2">{renderDropZone()}</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
// === BORDERED VARIANT (DEFAULT) ===
if (variant === "bordered" || variant === "flat") {
return (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleCollapsed(id)}>
<div className="w-full border border-dashed border-border rounded-lg overflow-hidden">
<CollapsibleTrigger className="w-full px-4 py-2 flex flex-row items-center justify-between hover:bg-muted/50 transition-colors select-none cursor-pointer">
{renderHeaderContent()}
{renderChevron(14)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-4 pt-2 pb-4">{renderDropZone()}</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
// === CARD VARIANT ===
return (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleCollapsed(id)}>
<Card className="w-full py-0 gap-0">
<CardHeader className="p-0 border-b-0">
<CollapsibleTrigger
className={cn(
"w-full px-4 py-3 flex flex-row items-center justify-between",
"hover:bg-muted/75 bg-muted/50 transition-colors select-none cursor-pointer",
!isCollapsed && "border-b",
)}
>
{renderHeaderContent()}
{renderChevron()}
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="p-4">{renderDropZone()}</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
};
registry/shadcn/builder/layout/group-renderer.tsx
"use client";
import { useEffect, useRef } from "react";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { DEFAULT_SLOT, useBuilderStore, type BuilderNodeRendererProps } from "@buildnbuzz/form-builder-react";
import type { GroupField } from "@buildnbuzz/form-core";
const spacingMap = {
sm: "space-y-2",
md: "space-y-3",
lg: "space-y-4",
} as const;
/**
* Visual renderer for 'group' container fields.
*/
export const GroupRenderer = ({ id, field, renderSlot, childrenIds }: BuilderNodeRendererProps) => {
const groupField = field as GroupField;
const { label } = groupField;
const variant = groupField.ui?.variant ?? "card";
const spacing = (groupField.ui?.spacing as keyof typeof spacingMap) ?? "md";
const defaultCollapsed = groupField.ui?.collapsed ?? false;
const isCollapsed = useBuilderStore(
(s) => s.collapsedNodes[id] ?? defaultCollapsed,
);
const toggleCollapsed = useBuilderStore((s) => s.toggleCollapsed);
const setCollapsed = useBuilderStore((s) => s.setCollapsed);
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
if (defaultCollapsed) {
setCollapsed(id, true);
}
}
}, [id, defaultCollapsed, setCollapsed]);
const { setNodeRef, isOver } = useDroppable({
id: `${id}-dropzone`,
data: { type: "group", parentId: id, parentSlot: DEFAULT_SLOT },
});
const isEmpty = childrenIds.length === 0;
const renderDropZone = () => (
<div
ref={setNodeRef}
data-container-padding
className={cn("min-h-16 transition-colors", isOver && "bg-primary/5")}
>
{isEmpty ? (
<div
className={cn(
"h-16 flex items-center justify-center rounded-lg border-2 border-dashed transition-colors",
isOver
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/20 bg-muted/30",
)}
>
<span className="text-muted-foreground text-sm italic">
Drop fields here
</span>
</div>
) : (
<div className={cn(spacingMap[spacing])}>
{renderSlot(DEFAULT_SLOT)}
</div>
)}
</div>
);
// === FLAT VARIANT ===
if (variant === "flat") {
return (
<div className="w-full">
{label && (
<div className="flex items-center gap-2 mb-2">
<IconPlaceholder
lucide="Folder"
hugeicons="FolderIcon"
size={14}
className="text-muted-foreground"
/>
<span className="font-medium text-sm text-foreground">{label as string}</span>
</div>
)}
<div className="pl-1">{renderDropZone()}</div>
</div>
);
}
// === GHOST VARIANT ===
if (variant === "ghost") {
return (
<div className="w-full border border-border/50 rounded-lg p-4">
{label && (
<div className="flex items-center gap-2 mb-3">
<IconPlaceholder
lucide="Folder"
hugeicons="FolderIcon"
size={14}
className="text-muted-foreground"
/>
<span className="font-medium text-sm text-foreground">{label as string}</span>
</div>
)}
{renderDropZone()}
</div>
);
}
// === BORDERED VARIANT ===
if (variant === "bordered") {
return (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleCollapsed(id)}>
<div className="w-full border border-dashed border-border rounded-lg overflow-hidden">
{label && (
<CollapsibleTrigger className="w-full px-4 py-2 flex flex-row items-center justify-between hover:bg-muted/50 transition-colors select-none cursor-pointer">
<div className="flex items-center gap-2">
<IconPlaceholder
lucide="Folder"
hugeicons="FolderIcon"
size={14}
className="text-muted-foreground"
/>
<span className="font-medium text-muted-foreground text-sm">
{label as string}
</span>
</div>
<IconPlaceholder
lucide="ChevronDown"
hugeicons="ArrowDown01Icon"
size={14}
className={cn(
"text-muted-foreground transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
/>
</CollapsibleTrigger>
)}
<CollapsibleContent>
<div className="px-4 pt-2 pb-4">{renderDropZone()}</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
// === CARD VARIANT (DEFAULT) ===
return (
<Collapsible open={!isCollapsed} onOpenChange={() => toggleCollapsed(id)}>
<Card className="w-full py-0 gap-0">
{label && (
<CardHeader className="p-0 border-b-0">
<CollapsibleTrigger
className={cn(
"w-full px-4 py-3 flex flex-row items-center justify-between",
"hover:bg-muted/75 bg-muted/50 transition-colors select-none cursor-pointer",
!isCollapsed && "border-b",
)}
>
<div className="flex items-center gap-2">
<IconPlaceholder
lucide="Folder"
hugeicons="FolderIcon"
size={16}
className="text-muted-foreground"
/>
<span className="text-sm font-semibold">{label as string}</span>
</div>
<IconPlaceholder
lucide="ChevronDown"
hugeicons="ArrowDown01Icon"
size={16}
className={cn(
"text-muted-foreground transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
/>
</CollapsibleTrigger>
</CardHeader>
)}
<CollapsibleContent>
<CardContent className="p-4">{renderDropZone()}</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
};
registry/shadcn/builder/layout/row-renderer.tsx
"use client";
import { useDroppable } from "@dnd-kit/core";
import { horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { cn } from "@/lib/utils";
import { IconPlaceholder } from "@/components/icon-placeholder";
import {
DEFAULT_SLOT,
type BuilderNodeRendererProps,
} from "@buildnbuzz/form-builder-react";
/**
* Visual renderer for 'row' layout fields.
* Displays children in a horizontal grid.
*/
export const RowRenderer = ({
id,
renderSlot,
childrenIds,
}: BuilderNodeRendererProps) => {
const { setNodeRef, isOver } = useDroppable({
id: `${id}-dropzone`,
data: { type: "row", parentId: id, parentSlot: DEFAULT_SLOT },
});
const isEmpty = childrenIds.length === 0;
return (
<div className="w-full">
{/* Header */}
<div className="flex items-center gap-1.5 mb-2 text-muted-foreground">
<IconPlaceholder lucide="Columns" hugeicons="InsertRowDownIcon" size={14} />
<span className="text-xs font-medium">Row</span>
</div>
{/* Drop zone container */}
<div
ref={setNodeRef}
data-container-padding
className={cn(
"flex flex-row gap-3 p-3 min-h-16 rounded-lg border-2 border-dashed transition-colors",
isEmpty ? "items-center justify-center" : "items-stretch",
isOver
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/20 bg-muted/30 hover:border-muted-foreground/30",
)}
>
{isEmpty ? (
<div className="text-muted-foreground text-sm italic">
Drop fields here
</div>
) : (
renderSlot(DEFAULT_SLOT, horizontalListSortingStrategy)
)}
</div>
</div>
);
};
registry/shadcn/builder/layout/tabs-renderer.tsx
"use client";
import React, { useMemo, useEffect } from "react";
import {
useBuilderStore,
type BuilderNodeRendererProps,
} from "@buildnbuzz/form-builder-react";
import { getTabSlotKeys } from "@buildnbuzz/form-builder-core";
import { useDroppable } from "@dnd-kit/core";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import type { TabsField, Tab } from "@buildnbuzz/form-core";
function getTabDisplayLabel(tab: Tab, index: number) {
const label = tab.label;
if (typeof label === "string" && label.trim().length > 0) {
return label;
}
if (typeof label === "number") {
return String(label);
}
return `Tab ${index + 1}`;
}
interface TabDropZoneProps {
containerId: string;
slot: string;
tab: Tab;
childrenIds: string[];
renderSlot: (slot: string) => React.ReactNode;
}
function TabDropZone({
containerId,
slot,
tab,
childrenIds,
renderSlot,
}: TabDropZoneProps) {
const { setNodeRef, isOver } = useDroppable({
id: `${containerId}::tab::${encodeURIComponent(slot)}-dropzone`,
data: { type: "tabs", parentId: containerId, parentSlot: slot },
});
const isEmpty = childrenIds.length === 0;
const description = (tab as unknown as Record<string, unknown>).description;
return (
<div
ref={setNodeRef}
data-container-padding
className={cn("min-h-16 transition-colors", isOver && "bg-primary/5")}
>
{typeof description === "string" && description && (
<p className="text-sm text-muted-foreground mb-3">{description}</p>
)}
{isEmpty ? (
<div
className={cn(
"h-16 flex items-center justify-center rounded-lg border-2 border-dashed transition-colors",
isOver
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/20 bg-muted/30",
)}
>
<span className="text-muted-foreground text-sm italic">
Drop fields in this tab
</span>
</div>
) : (
renderSlot(slot)
)}
</div>
);
}
/**
* Visual renderer for 'tabs' fields.
* Orchestrates multiple slots as tabs.
*/
export const TabsRenderer = ({
id,
field,
renderSlot,
}: BuilderNodeRendererProps) => {
const tabsField = field as TabsField;
const tabs = useMemo(() => tabsField.tabs ?? [], [tabsField.tabs]);
const slots = useMemo(() => getTabSlotKeys(tabs), [tabs]);
const activeSlotFromStore = useBuilderStore(
(state) => state.activeTabs[id] ?? null,
);
const setActiveTab = useBuilderStore((state) => state.setActiveTab);
const tabChildren = useBuilderStore(
(state) => state.nodes[id]?.children ?? {},
);
const defaultSlot = useMemo(() => {
if (slots.length === 0) return "";
const configuredDefault = tabsField.ui?.defaultTab;
const enabledSlots = slots.filter(
(_, index) => tabs[index]?.disabled !== true,
);
const enabledFallbackSlot = enabledSlots[0] ?? slots[0];
if (typeof configuredDefault === "number") {
const index = Math.max(0, Math.min(slots.length - 1, configuredDefault));
return tabs[index]?.disabled === true
? enabledFallbackSlot
: slots[index];
}
if (typeof configuredDefault === "string") {
const index = tabs.findIndex((tab) => tab.name === configuredDefault);
if (index >= 0 && tabs[index]?.disabled !== true) {
return slots[index];
}
}
return enabledFallbackSlot;
}, [tabsField.ui?.defaultTab, tabs, slots]);
const activeSlot =
activeSlotFromStore && slots.includes(activeSlotFromStore)
? activeSlotFromStore
: defaultSlot;
useEffect(() => {
if (!activeSlot) return;
if (activeSlotFromStore !== activeSlot) {
setActiveTab(id, activeSlot);
}
}, [activeSlot, activeSlotFromStore, id, setActiveTab]);
if (tabs.length === 0) {
return (
<div className="w-full rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<IconPlaceholder lucide="Layout" hugeicons="Layout01Icon" size={14} />
<span className="text-xs font-medium">Tabs</span>
</div>
<p className="text-sm text-muted-foreground">
Add at least one tab in the properties panel.
</p>
</div>
);
}
return (
<div className="w-full">
<div className="flex items-center gap-1.5 mb-2 text-muted-foreground">
<IconPlaceholder lucide="Layout" hugeicons="Layout01Icon" size={14} />
<span className="text-xs font-medium">Tabs</span>
</div>
<Tabs
value={activeSlot}
onValueChange={(val) => val && setActiveTab(id, val)}
className="w-full"
>
<TabsList
variant={(tabsField.ui?.variant as "line" | "default") ?? "line"}
className="w-full justify-start"
>
{tabs.map((tab, index) => {
const slot = slots[index];
const childCount = tabChildren[slot]?.length ?? 0;
return (
<TabsTrigger
key={slot}
value={slot}
className={cn(
tab.disabled &&
"opacity-80 text-muted-foreground data-[state=active]:text-muted-foreground",
)}
>
<span className="inline-flex items-center gap-1.5">
<span>{getTabDisplayLabel(tab, index)}</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{childCount}
</Badge>
{tab.disabled && (
<IconPlaceholder
lucide="Ban"
hugeicons="BlockedIcon"
size={12}
className="text-muted-foreground/70"
/>
)}
</span>
</TabsTrigger>
);
})}
</TabsList>
{tabs.map((tab, index) => {
const slot = slots[index];
const childrenIdsForSlot = tabChildren[slot] || [];
return (
<TabsContent key={slot} value={slot} className="mt-4">
{tab.name && (
<p className="text-xs text-muted-foreground mb-2">
Key: <code className="font-mono">{tab.name}</code>
{tab.disabled && (
<Badge
variant="outline"
className="ml-2 h-5 gap-1 border-muted-foreground/20 bg-muted/50 px-1.5 text-[10px] font-normal text-muted-foreground"
>
<IconPlaceholder
lucide="Ban"
hugeicons="BlockedIcon"
size={12}
/>
Disabled
</Badge>
)}
</p>
)}
<TabDropZone
containerId={id}
slot={slot}
tab={tab}
childrenIds={childrenIdsForSlot}
renderSlot={renderSlot}
/>
</TabsContent>
);
})}
</Tabs>
</div>
);
};
registry/shadcn/builder/mobile-overlay.tsx
"use client";
import { useIsMobile } from "@/hooks/use-mobile";
import { IconPlaceholder } from "@/components/icon-placeholder";
/**
* Overlay shown on mobile devices to prompt users to use a desktop browser.
*/
export function MobileOverlay() {
const isMobile = useIsMobile();
if (!isMobile) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-6 backdrop-blur-md lg:hidden">
<div className="relative w-full max-w-sm overflow-hidden rounded-3xl border border-border/60 bg-card/90 shadow-2xl shadow-black/20">
<div className="absolute -left-16 -top-16 h-44 w-44 rounded-full bg-primary/20 blur-3xl" />
<div className="absolute -bottom-20 -right-10 h-48 w-48 rounded-full bg-sky-500/15 blur-3xl" />
<div className="relative space-y-4 p-8 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-border/60 bg-background/70 shadow-sm">
<IconPlaceholder
lucide="Monitor"
hugeicons="ComputerIcon"
tabler="IconDeviceDesktop"
phosphor="Monitor"
remixicon="RiComputerLine"
size={24}
className="text-primary"
/>
</div>
<div className="space-y-2">
<h1 className="text-xl font-bold tracking-tight">
Best on Desktop
</h1>
<p className="text-sm text-muted-foreground leading-relaxed">
BuzzForm Builder requires a larger screen to provide the best
editing experience. Please switch to a desktop or tablet.
</p>
</div>
</div>
</div>
</div>
);
}
registry/shadcn/builder/node-wrapper.tsx
"use client";
import React, { useState } from "react";
import { useBuilderStore, SortableNode } from "@buildnbuzz/form-builder-react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { Node } from "@buildnbuzz/form-builder-core";
import { isDataField, isContainerType } from "@buildnbuzz/form-core";
interface NodeWrapperProps {
node: Node;
children: React.ReactNode;
}
/**
* Visual frame for canvas nodes.
* Provides selection states, drag handling, and a contextual toolbar.
*/
export const NodeWrapper = ({ node, children }: NodeWrapperProps) => {
const selectedId = useBuilderStore((s) => s.selectedId);
const selectNode = useBuilderStore((s) => s.selectNode);
const removeNode = useBuilderStore((s) => s.removeNode);
const duplicateNode = useBuilderStore((s) => s.duplicateNode);
const [isHovered, setIsHovered] = useState(false);
const isSelected = selectedId === node.id;
const isHidden = isDataField(node.field) && node.field.hidden === true;
const fieldStyle = "style" in node.field ? node.field.style : undefined;
const fieldWidth = (fieldStyle as React.CSSProperties | undefined)?.width;
return (
<SortableNode
id={node.id}
render={({ setNodeRef, attributes, listeners, style, isDragging }) => (
<div
ref={setNodeRef}
style={{
...style,
...(isHidden && {
backgroundImage:
"repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(128,128,128,0.05) 10px, rgba(128,128,128,0.05) 20px)",
}),
...(fieldWidth && fieldWidth !== "auto"
? { width: fieldWidth, flex: "0 0 auto" }
: { flex: "1 1 0%", minWidth: 0 }),
}}
onClick={(e) => {
e.stopPropagation();
selectNode(node.id);
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
{...attributes}
{...listeners}
className={cn(
"relative group/node my-2 rounded-xl transition-all duration-200 cursor-default",
"hover:p-2",
isSelected
? "border-primary/50 ring-2 ring-primary/10 p-2 bg-primary/5"
: "border-transparent",
!isSelected && "border hover:border-border/40",
isDragging && "opacity-30",
isHidden &&
"opacity-60 grayscale border-dashed border-muted-foreground/30",
)}
>
{/* Node Toolbar - visible on hover or selection */}
<TooltipProvider>
<div
className={cn(
"absolute -top-9 right-0 z-50 flex items-center gap-0.5 rounded-lg border border-border/50 bg-card/75 p-1 shadow-lg shadow-black/10 backdrop-blur-md transition-opacity duration-200",
isSelected || isHovered
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none",
)}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
duplicateNode(node.id);
}}
/>
}
>
<IconPlaceholder
lucide="Copy"
hugeicons="Copy01Icon"
tabler="IconCopy"
phosphor="Copy"
remixicon="RiFileCopyLine"
size={14}
/>
</TooltipTrigger>
<TooltipContent>Duplicate field</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeNode(node.id);
}}
/>
}
>
<IconPlaceholder
lucide="Trash2"
hugeicons="Delete02Icon"
tabler="IconTrash"
phosphor="Trash"
remixicon="RiDeleteBinLine"
size={14}
/>
</TooltipTrigger>
<TooltipContent>Remove field</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
{/* Node Content */}
<div
className={cn(
"relative w-full transition-all duration-200",
!isContainerType(node.field.type) && "pointer-events-none",
)}
>
{isHidden && (
<Badge
className="absolute bottom-1.5 right-2 z-10 select-none bg-background/80 backdrop-blur-sm pointer-events-auto"
variant="outline"
>
Hidden
</Badge>
)}
{children}
</div>
</div>
)}
/>
);
};
registry/shadcn/builder/preview/preview-form.tsx
"use client";
import { useBuilderFormContext } from "@buildnbuzz/form-builder-react";
import {
Form,
FormContent,
FormFields,
FormActions,
FormSubmit,
} from "@/registry/shadcn/form";
/**
* PreviewForm: Renders the builder's current state as a functional shadcn-based form.
* Uses the form instance provided by BuilderFormProvider.
*/
export function PreviewForm() {
const { form, fields } = useBuilderFormContext();
return (
<Form form={form} schema={{ fields }}>
<FormContent noValidate className="p-0 gap-6">
<FormFields />
<FormActions className="mt-4" align="start">
<FormSubmit>Submit</FormSubmit>
</FormActions>
</FormContent>
</Form>
);
}
registry/shadcn/builder/property-panel.tsx
"use client";
import React from "react";
import {
useBuilderContext,
BuilderProperties,
useSyncProperty,
getRegistryEntry,
} from "@buildnbuzz/form-builder-react";
import { Form, FormContent, useFormContext } from "@/registry/shadcn/form";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FieldIcon } from "./field-icon";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { flattenFieldToFormValues } from "@buildnbuzz/form-builder-core";
import type { Field, FormSchema } from "@buildnbuzz/form-core";
import { ExpressionCheckbox } from "./expression-builder/expression-checkbox";
import { registry as shadcnRegistry } from "../registry";
/** Renders nothing — syncs form state ↔ builder store bidirectionally. */
function SyncPropertyState({
update,
data,
schema,
}: {
update: (data: Record<string, unknown>) => void;
data: Record<string, unknown>;
schema: Field[];
}) {
const { form } = useFormContext();
useSyncProperty({ form, data, schema, update });
return null;
}
/**
* Side panel for editing node or global form properties.
*/
export const PropertyPanel = () => {
const { registry } = useBuilderContext();
return (
<div className="flex h-full flex-col bg-background border-l overflow-hidden">
<BuilderProperties
render={({ node, id, schema, data, update }) => {
const entry = node
? getRegistryEntry(registry, node.field.type)
: null;
const label =
entry?.sidebar.label ??
(id === "form" ? "Form Settings" : "Properties");
const defaultValues = flattenFieldToFormValues(
data as unknown as Field,
schema,
);
return (
<>
<div className="p-4 border-b h-header flex items-center">
{node && entry ? (
<div className="flex items-center gap-3 w-full">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border bg-secondary/30 shadow-sm">
{node && (
<FieldIcon
type={node.field.type}
size={20}
className="text-foreground"
/>
)}
</div>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="font-semibold text-sm truncate">
{label}
</span>
<span className="text-[11px] text-muted-foreground/80 truncate">
Properties
</span>
</div>
</div>
) : (
<div className="flex items-center gap-3 w-full">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border bg-secondary/30 shadow-sm">
<IconPlaceholder
lucide="Settings"
hugeicons="Settings01Icon"
size={20}
className="text-muted-foreground"
/>
</div>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="font-semibold text-sm">
Form Settings
</span>
<span className="text-[11px] text-muted-foreground/80 truncate">
Configuration
</span>
</div>
</div>
)}
</div>
<div className="flex-1 min-h-0">
<ScrollArea className="h-full">
<div className="flex flex-col gap-6 p-4">
{schema.length > 0 ? (
<Form
key={id}
schema={{ fields: schema } as unknown as FormSchema}
defaultValues={defaultValues}
registries={{
fields: {
...shadcnRegistry,
checkbox: ExpressionCheckbox,
switch: ExpressionCheckbox,
},
}}
>
<FormContent autoRender as="div" className="gap-6">
<SyncPropertyState
update={update}
data={data}
schema={schema}
/>
</FormContent>
</Form>
) : (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center opacity-30">
<span className="text-[11px]">
No properties available
</span>
</div>
)}
</div>
</ScrollArea>
</div>
</>
);
}}
/>
</div>
);
};
registry/shadcn/builder/registry.tsx
"use client";
import React from "react";
import {
DEFAULT_FIELD_REGISTRY,
isDataField,
} from "@buildnbuzz/form-builder-core";
import { Field, type DataField } from "@buildnbuzz/form-react";
import { registry as shadcnRegistry } from "../registry";
import { RowRenderer } from "./layout/row-renderer";
import { GroupRenderer } from "./layout/group-renderer";
import { ArrayRenderer } from "./layout/array-renderer";
import { TabsRenderer } from "./layout/tabs-renderer";
import { CollapsibleRenderer } from "./layout/collapsible-renderer";
import {
useBuilderFormContext,
type BuilderNodeRendererProps,
type BuilderFieldRegistry,
} from "@buildnbuzz/form-builder-react";
/**
* Generic renderer for data fields that uses the runtime components.
* Wraps the component in a Field to provide the necessary headless context.
*/
const DataFieldRenderer = ({ field }: BuilderNodeRendererProps) => {
const { form } = useBuilderFormContext();
const Component = shadcnRegistry[field.type];
if (!Component || !isDataField(field)) {
return null;
}
return (
<Field field={field as DataField} form={form}>
<Component />
</Field>
);
};
const specializedRenderers: Record<
string,
React.ComponentType<BuilderNodeRendererProps>
> = {
row: RowRenderer,
group: GroupRenderer,
array: ArrayRenderer,
tabs: TabsRenderer,
collapsible: CollapsibleRenderer,
};
/**
* Shadcn-specific builder registry.
* Hydrates the core metadata registry with React renderers.
*/
export const SHADCN_BUILDER_REGISTRY: BuilderFieldRegistry = Object.entries(
DEFAULT_FIELD_REGISTRY,
).reduce((acc, [type, entry]) => {
if (!entry) return acc;
acc[type] = {
...entry,
renderer: (specializedRenderers[type] ??
DataFieldRenderer) as React.ComponentType<BuilderNodeRendererProps>,
};
return acc;
}, {} as BuilderFieldRegistry);