{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "title": "File Dropzone",
  "description": "Drag and drop upload with validation, progress and rejection states.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/ui/file-dropzone.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { FileIcon, UploadIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\n\ntype FileRejectionReason = \"type\" | \"size\" | \"count\"\n\ntype FileRejection = {\n  file: File\n  reason: FileRejectionReason\n}\n\nconst BYTE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\"]\n\nfunction formatBytes(bytes: number) {\n  const exponent = Math.min(\n    Math.floor(Math.log(Math.max(bytes, 1)) / Math.log(1024)),\n    BYTE_UNITS.length - 1\n  )\n  const amount = bytes / 1024 ** exponent\n  return `${amount.toFixed(exponent === 0 || amount >= 10 ? 0 : 1)} ${BYTE_UNITS[exponent]}`\n}\n\nfunction matchesAccept(file: File, accept?: string) {\n  if (!accept) return true\n\n  return accept\n    .split(\",\")\n    .map((token) => token.trim().toLowerCase())\n    .some((token) => {\n      if (token.startsWith(\".\")) return file.name.toLowerCase().endsWith(token)\n      if (token.endsWith(\"/*\")) return file.type.startsWith(token.slice(0, -1))\n      return file.type === token\n    })\n}\n\ntype FileLimits = { accept?: string; maxSize?: number; maxFiles?: number }\n\nfunction rejectionReasonFor(\n  file: File,\n  acceptedCount: number,\n  { accept, maxSize, maxFiles }: FileLimits\n): FileRejectionReason | null {\n  if (!matchesAccept(file, accept)) return \"type\"\n  if (maxSize !== undefined && file.size > maxSize) return \"size\"\n  if (maxFiles !== undefined && acceptedCount >= maxFiles) return \"count\"\n  return null\n}\n\nfunction partitionFiles(files: File[], limits: FileLimits) {\n  const accepted: File[] = []\n  const rejected: FileRejection[] = []\n\n  for (const file of files) {\n    const reason = rejectionReasonFor(file, accepted.length, limits)\n    if (reason) rejected.push({ file, reason })\n    else accepted.push(file)\n  }\n\n  return { accepted, rejected }\n}\n\nfunction FileDropzone({\n  accept,\n  maxSize,\n  maxFiles,\n  multiple = true,\n  disabled,\n  onFilesAccepted,\n  onFilesRejected,\n  className,\n  children,\n  ...props\n}: Omit<React.ComponentProps<\"label\">, \"onDrop\"> & {\n  accept?: string\n  maxSize?: number\n  maxFiles?: number\n  multiple?: boolean\n  disabled?: boolean\n  onFilesAccepted?: (files: File[]) => void\n  onFilesRejected?: (rejections: FileRejection[]) => void\n}) {\n  const [isDragging, setIsDragging] = React.useState(false)\n\n  function receiveFiles(fileList: FileList | null) {\n    if (!fileList || disabled) return\n    const files = multiple\n      ? Array.from(fileList)\n      : Array.from(fileList).slice(0, 1)\n    const { accepted, rejected } = partitionFiles(files, {\n      accept,\n      maxSize,\n      maxFiles,\n    })\n    if (accepted.length > 0) onFilesAccepted?.(accepted)\n    if (rejected.length > 0) onFilesRejected?.(rejected)\n  }\n\n  return (\n    <label\n      data-slot=\"file-dropzone\"\n      data-dragging={isDragging ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      onDragEnter={(event) => {\n        event.preventDefault()\n        if (!disabled) setIsDragging(true)\n      }}\n      onDragOver={(event) => event.preventDefault()}\n      onDragLeave={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null))\n          setIsDragging(false)\n      }}\n      onDrop={(event) => {\n        event.preventDefault()\n        setIsDragging(false)\n        receiveFiles(event.dataTransfer.files)\n      }}\n      className={cn(\n        \"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-input px-6 py-8 text-center transition-[color,background-color,border-color,box-shadow]\",\n        \"hover:bg-accent/40 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50\",\n        \"data-[dragging]:border-primary data-[dragging]:bg-accent\",\n        \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <input\n        type=\"file\"\n        className=\"sr-only\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(event) => {\n          receiveFiles(event.target.files)\n          event.target.value = \"\"\n        }}\n      />\n      {children ?? (\n        <>\n          <span className=\"flex size-10 items-center justify-center rounded-full border bg-background text-muted-foreground [&_svg]:size-4\">\n            <UploadIcon />\n          </span>\n          <span className=\"text-sm font-medium\">\n            Drop files or{\" \"}\n            <span className=\"underline underline-offset-4\">browse</span>\n          </span>\n          {maxSize !== undefined && (\n            <span className=\"text-xs text-muted-foreground\">\n              Up to {formatBytes(maxSize)} each\n            </span>\n          )}\n        </>\n      )}\n    </label>\n  )\n}\n\nfunction FileDropzoneList({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"file-dropzone-list\"\n      className={cn(\"flex flex-col gap-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction FileDropzoneItem({\n  name,\n  size,\n  progress,\n  error,\n  onRemove,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"li\">, \"children\"> & {\n  name: string\n  size: number\n  progress?: number\n  error?: string\n  onRemove?: () => void\n}) {\n  const isUploading = progress !== undefined && progress < 100 && !error\n\n  return (\n    <li\n      data-slot=\"file-dropzone-item\"\n      data-invalid={error ? \"\" : undefined}\n      className={cn(\n        \"flex items-center gap-3 rounded-md border px-3 py-2.5 data-[invalid]:border-destructive/50\",\n        className\n      )}\n      {...props}\n    >\n      <FileIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n        <div className=\"flex items-baseline justify-between gap-3 text-sm\">\n          <span className=\"truncate\">{name}</span>\n          <span className=\"shrink-0 font-mono text-xs text-muted-foreground tabular-nums\">\n            {isUploading ? `${Math.round(progress)}%` : formatBytes(size)}\n          </span>\n        </div>\n        {isUploading && (\n          <div\n            role=\"progressbar\"\n            aria-label={`Uploading ${name}`}\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={progress}\n            className=\"h-1 overflow-hidden rounded-full bg-muted\"\n          >\n            <div\n              className=\"h-full bg-primary transition-transform duration-300\"\n              style={{ transform: `translateX(-${100 - progress}%)` }}\n            />\n          </div>\n        )}\n        {error && <span className=\"text-xs text-destructive\">{error}</span>}\n      </div>\n      {onRemove && (\n        <button\n          type=\"button\"\n          aria-label={`Remove ${name}`}\n          onClick={onRemove}\n          className=\"rounded-sm text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n        >\n          <XIcon className=\"size-4\" />\n        </button>\n      )}\n    </li>\n  )\n}\n\nexport {\n  FileDropzone,\n  FileDropzoneList,\n  FileDropzoneItem,\n  formatBytes,\n  type FileRejection,\n  type FileRejectionReason,\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}