{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "title": "File Dropzone",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Drop files or browse with native file selection, type and size validation, removable file rows, and live errors.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/file-dropzone.tsx",
      "content": "// Ballmac UI: File Dropzone. https://ui.ballmac.com/components/file-dropzone\n\"use client\"\n\nimport * as React from \"react\"\nimport { FileUp, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype FileDropzoneProps = Omit<React.ComponentProps<\"div\">, \"onChange\"> & {\n  /** Accepted MIME types or extensions, in native input accept syntax. */\n  accept?: string\n  /** Allow more than one file. */\n  multiple?: boolean\n  /** Maximum number of files in the selection. */\n  maxFiles?: number\n  /** Maximum size of each file in bytes. */\n  maxSize?: number\n  /** Controlled file selection. */\n  files?: File[]\n  /** Initial selection when uncontrolled. */\n  defaultFiles?: File[]\n  /** Called with the complete selection after add or remove. */\n  onFilesChange?: (files: File[]) => void\n  /** Disable browsing and dropping. */\n  disabled?: boolean\n}\n\nfunction FileDropzone({\n  accept,\n  multiple = true,\n  maxFiles = multiple ? 5 : 1,\n  maxSize = 10 * 1024 * 1024,\n  files,\n  defaultFiles = [],\n  onFilesChange,\n  disabled = false,\n  className,\n  onDragEnter,\n  onDragLeave,\n  onDragOver,\n  onDrop,\n  ...props\n}: FileDropzoneProps) {\n  const msg = useMessages()\n  const [internalFiles, setInternalFiles] = React.useState<File[]>(defaultFiles)\n  const [dragging, setDragging] = React.useState(false)\n  const [error, setError] = React.useState(\"\")\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const id = React.useId()\n  const selected = files ?? internalFiles\n\n  function commit(next: File[]) {\n    if (files === undefined) setInternalFiles(next)\n    onFilesChange?.(next)\n  }\n  function add(incoming: File[]) {\n    const allowed =\n      accept\n        ?.split(\",\")\n        .map((part) => part.trim().toLowerCase())\n        .filter(Boolean) ?? []\n    const valid = incoming.filter((file) => {\n      const accepted =\n        !allowed.length ||\n        allowed.some((part) =>\n          part.startsWith(\".\")\n            ? file.name.toLowerCase().endsWith(part)\n            : part.endsWith(\"/*\")\n              ? file.type.startsWith(part.slice(0, -1))\n              : file.type.toLowerCase() === part,\n        )\n      if (!accepted) setError(`${file.name} is not an accepted file type.`)\n      else if (file.size > maxSize)\n        setError(`${file.name} exceeds the size limit.`)\n      return accepted && file.size <= maxSize\n    })\n    const next = multiple ? [...selected, ...valid] : valid.slice(0, 1)\n    if (next.length > maxFiles) {\n      setError(`Choose no more than ${maxFiles} files.`)\n      return\n    }\n    if (valid.length) {\n      setError(\"\")\n      commit(next)\n    }\n  }\n\n  return (\n    <div\n      data-slot=\"file-dropzone\"\n      className={cn(\"w-full min-w-0\", className)}\n      {...props}\n    >\n      <div\n        data-slot=\"file-dropzone-area\"\n        data-dragging={dragging || undefined}\n        className=\"flex flex-col items-center rounded-xl border border-dashed border-input bg-card/50 px-5 py-7 text-center transition-[background-color,border-color] duration-150 data-[dragging]:border-ring data-[dragging]:bg-accent motion-reduce:transition-none\"\n        onDragEnter={(event) => {\n          onDragEnter?.(event)\n          event.preventDefault()\n          if (!disabled) setDragging(true)\n        }}\n        onDragLeave={(event) => {\n          onDragLeave?.(event)\n          if (!event.currentTarget.contains(event.relatedTarget as Node))\n            setDragging(false)\n        }}\n        onDragOver={(event) => {\n          onDragOver?.(event)\n          event.preventDefault()\n        }}\n        onDrop={(event) => {\n          onDrop?.(event)\n          event.preventDefault()\n          setDragging(false)\n          if (!disabled) add(Array.from(event.dataTransfer.files))\n        }}\n      >\n        <FileUp\n          aria-hidden=\"true\"\n          className=\"mb-3 size-6 text-muted-foreground\"\n        />\n        <p className=\"text-sm font-medium\">{msg(\"file-dropzone.dropFilesHereOrBrowse\", \"Drop files here or browse\")}</p>\n        <p id={id} className=\"mt-1 text-xs text-muted-foreground\">\n          {multiple ? `Up to ${maxFiles} files` : \"One file\"} ·{\" \"}\n          {msg(\"file-dropzone.maxSize\", \"{size} MB each\", { size: Math.round(maxSize / 1024 / 1024) })}\n        </p>\n        <button\n          type=\"button\"\n          disabled={disabled}\n          onClick={() => inputRef.current?.click()}\n          className=\"mt-4 inline-flex h-9 items-center rounded-md border border-border bg-background px-3 text-sm font-medium shadow-xs outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n        >\n          {msg(\"file-dropzone.choose\", \"Choose\")} {multiple ? \"files\" : \"file\"}\n        </button>\n        <input\n          ref={inputRef}\n          type=\"file\"\n          aria-label={msg(\"file-dropzone.filesToUpload\", \"Files to upload\")}\n          accept={accept}\n          multiple={multiple}\n          disabled={disabled}\n          aria-describedby={id}\n          tabIndex={-1}\n          className=\"sr-only\"\n          onChange={(event) => {\n            add(Array.from(event.currentTarget.files ?? []))\n            event.currentTarget.value = \"\"\n          }}\n        />\n      </div>\n      {error && (\n        <p\n          data-slot=\"file-dropzone-error\"\n          role=\"alert\"\n          className=\"mt-2 text-sm text-destructive\"\n        >\n          {error}\n        </p>\n      )}\n      {selected.length > 0 && (\n        <ul\n          data-slot=\"file-dropzone-files\"\n          className=\"mt-3 flex flex-col gap-2\"\n        >\n          {selected.map((file, index) => (\n            <li\n              key={`${file.name}-${index}`}\n              className=\"flex min-w-0 items-center gap-2 rounded-lg border bg-card px-3 py-2 text-sm\"\n            >\n              <span className=\"min-w-0 flex-1 truncate\">{file.name}</span>\n              <span className=\"text-xs text-muted-foreground tabular-nums\">\n                {msg(\"file-dropzone.sizeKb\", \"{size} KB\", { size: Math.max(1, Math.round(file.size / 1024)) })}\n              </span>\n              <button\n                type=\"button\"\n                aria-label={msg(\"file-dropzone.remove\", \"Remove {name}\", { name: file.name })}\n                disabled={disabled}\n                onClick={() =>\n                  commit(selected.filter((_, position) => position !== index))\n                }\n                className=\"flex size-7 shrink-0 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50\"\n              >\n                <X aria-hidden=\"true\" className=\"size-4\" />\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  )\n}\n\nexport { FileDropzone, type FileDropzoneProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/file-dropzone.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "upload",
      "files",
      "dropzone"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "Drop files or browse with native file selection, type and size validation, removable file rows, and live errors.",
      "whenToUse": [
        "Upload a document or group of assets",
        "Review selected files before submitting"
      ],
      "whenNotToUse": [
        "Use input for a minimal single-file field"
      ],
      "composesWith": [
        "button"
      ],
      "a11y": [
        {
          "keys": "Tab / Enter",
          "action": "Opens the native file chooser"
        },
        {
          "keys": "Drop",
          "action": "Adds valid files"
        },
        {
          "keys": "Tab / Enter",
          "action": "Removes a selected file"
        }
      ],
      "customization": [
        "accept, maxFiles, maxSize, multiple",
        "files / defaultFiles and onFilesChange"
      ]
    },
    "examples": [
      "file-dropzone-demo",
      "file-dropzone-states"
    ],
    "url": "https://ui.ballmac.com/components/file-dropzone"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}