{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "type": "registry:ui",
  "title": "Dropzone",
  "description": "Drag-and-drop file upload area with click-to-open fallback. Self-contained (no react-dropzone).",
  "dependencies": [
    "@saasflare/ui"
  ],
  "files": [
    {
      "path": "components/ui/dropzone.tsx",
      "type": "registry:ui",
      "content": "// @draft\n\"use client\"\n\n/**\n * @fileoverview Saasflare Dropzone — drag-and-drop file upload area.\n * @author Saasflare™\n *\n * Self-contained dropzone: handles drag-over highlight, drop, click-to-open\n * (via {@link useFileDialog}), and disabled / max-size / accept filtering.\n * Calls back with the accepted + rejected file lists; rendering of the\n * accepted file UI is left to the consumer (or the default body slot).\n *\n * Saasflare does not bundle `react-dropzone`; this is a ~150-line replacement\n * that integrates with the surface/radius/animated system.\n *\n * @module packages/ui/components/ui/dropzone\n * @package ui\n * @layer core\n *\n * @example\n * <Dropzone\n *   accept=\"image/*\"\n *   maxSize={5 * 1024 * 1024}\n *   onDrop={(accepted, rejected) => upload(accepted)}\n * />\n */\n\nimport { useCallback, useRef, useState, type DragEvent, type ReactNode } from \"react\"\nimport { cn } from \"@saasflare/ui\"\nimport { useSaasflareProps, type SaasflareComponentProps } from \"@saasflare/ui\"\nimport { useFileDialog } from \"@saasflare/ui\"\n\n/** Reason a file was rejected by the dropzone's built-in validation. */\nexport type DropzoneRejectionReason = \"too-large\" | \"type-mismatch\"\n\n/** A rejected file with the reason. */\nexport interface DropzoneRejection {\n    file: File\n    reason: DropzoneRejectionReason\n}\n\n/** Props for the Dropzone component. */\nexport interface DropzoneProps extends SaasflareComponentProps {\n    /** Called with `(accepted, rejected)` when the user drops or picks files. */\n    onDrop?: (accepted: File[], rejected: DropzoneRejection[]) => void\n    /** MIME types or extensions to accept (matches `<input accept>`). */\n    accept?: string\n    /** Maximum file size in bytes. */\n    maxSize?: number\n    /** Allow multiple file selection. Default: `true`. */\n    multiple?: boolean\n    /** Disable the dropzone. */\n    disabled?: boolean\n    /** Render-prop body. Receives `isDragActive`. Falls back to the default UI. */\n    children?: ReactNode | ((state: { isDragActive: boolean }) => ReactNode)\n    /** Additional class names. */\n    className?: string\n}\n\nfunction matchesAccept(file: File, accept?: string): boolean {\n    if (!accept) return true\n    const parts = accept.split(\",\").map((s) => s.trim().toLowerCase())\n    const name = file.name.toLowerCase()\n    const type = file.type.toLowerCase()\n    return parts.some((p) => {\n        if (p.startsWith(\".\")) return name.endsWith(p)\n        if (p.endsWith(\"/*\")) return type.startsWith(p.slice(0, -1))\n        return type === p\n    })\n}\n\nfunction partition(\n    files: File[],\n    accept?: string,\n    maxSize?: number,\n): { accepted: File[]; rejected: DropzoneRejection[] } {\n    const accepted: File[] = []\n    const rejected: DropzoneRejection[] = []\n    for (const file of files) {\n        if (!matchesAccept(file, accept)) {\n            rejected.push({ file, reason: \"type-mismatch\" })\n            continue\n        }\n        if (typeof maxSize === \"number\" && file.size > maxSize) {\n            rejected.push({ file, reason: \"too-large\" })\n            continue\n        }\n        accepted.push(file)\n    }\n    return { accepted, rejected }\n}\n\n/**\n * Drag-and-drop file upload area with click-to-open fallback.\n *\n * @component\n * @layer core\n */\nexport function Dropzone({\n    onDrop,\n    accept,\n    maxSize,\n    multiple = true,\n    disabled = false,\n    children,\n    className,\n    surface,\n    radius,\n    animated,\n    iconWeight,\n}: DropzoneProps) {\n    const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n    const [isDragActive, setIsDragActive] = useState(false)\n    const dragDepth = useRef(0)\n\n    const handle = useCallback(\n        (files: File[]) => {\n            const { accepted, rejected } = partition(files, accept, maxSize)\n            onDrop?.(accepted, rejected)\n        },\n        [accept, maxSize, onDrop],\n    )\n\n    const { open } = useFileDialog({\n        accept,\n        multiple,\n        onChange: handle,\n    })\n\n    const onDragEnter = (e: DragEvent<HTMLDivElement>) => {\n        e.preventDefault()\n        if (disabled) return\n        // Count enter/leave across descendants so the active state doesn't\n        // flicker as the cursor crosses child element boundaries.\n        dragDepth.current += 1\n        setIsDragActive(true)\n    }\n    const onDragOver = (e: DragEvent<HTMLDivElement>) => {\n        e.preventDefault() // mark the element as a valid drop target\n    }\n    const onDragLeave = (e: DragEvent<HTMLDivElement>) => {\n        e.preventDefault()\n        dragDepth.current = Math.max(0, dragDepth.current - 1)\n        if (dragDepth.current === 0) setIsDragActive(false)\n    }\n    const onDropEvent = (e: DragEvent<HTMLDivElement>) => {\n        e.preventDefault()\n        dragDepth.current = 0\n        setIsDragActive(false)\n        if (disabled) return\n        const files = Array.from(e.dataTransfer.files)\n        handle(multiple ? files : files.slice(0, 1))\n    }\n\n    return (\n        <div\n            data-slot=\"dropzone\"\n            data-surface={sf.surface}\n            data-radius={sf.radius}\n            data-animated={String(sf.animated)}\n            data-active={String(isDragActive)}\n            data-disabled={String(disabled)}\n            role=\"button\"\n            tabIndex={disabled ? -1 : 0}\n            aria-disabled={disabled || undefined}\n            onClick={() => !disabled && open()}\n            onKeyDown={(e) => {\n                if (disabled) return\n                if (e.key === \"Enter\" || e.key === \" \") {\n                    e.preventDefault()\n                    open()\n                }\n            }}\n            onDragOver={onDragOver}\n            onDragEnter={onDragEnter}\n            onDragLeave={onDragLeave}\n            onDrop={onDropEvent}\n            className={cn(\n                \"flex min-h-32 cursor-pointer items-center justify-center rounded-xl border-2 border-dashed\",\n                \"border-border bg-background/40 p-6 text-center text-sm text-muted-foreground\",\n                \"transition-[border-color,background-color] duration-200\",\n                \"hover:border-primary/40 hover:bg-primary/5\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                \"data-[active=true]:border-primary/60 data-[active=true]:bg-primary/10\",\n                \"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50\",\n                className,\n            )}\n        >\n            {typeof children === \"function\" ? (\n                children({ isDragActive })\n            ) : children !== undefined ? (\n                children\n            ) : (\n                <div className=\"flex flex-col items-center gap-1\">\n                    <p className=\"font-medium text-foreground\">\n                        {isDragActive ? \"Drop files here\" : \"Drag files here or click to browse\"}\n                    </p>\n                    {accept && <p className=\"text-xs\">Accepts: {accept}</p>}\n                    {typeof maxSize === \"number\" && (\n                        <p className=\"text-xs\">Max size: {(maxSize / 1024 / 1024).toFixed(1)} MB</p>\n                    )}\n                </div>\n            )}\n        </div>\n    )\n}\n",
      "target": "components/ui/dropzone.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/dropzone.json"
  }
}
