{
  "$schema": "https://abackui.com/schema/registry-item.json",
  "name": "dropzone",
  "type": "registry:ui",
  "title": "Dropzone",
  "description": "A keyboard-operable drag-and-drop file area that validates type, size, and count.",
  "dependencies": [
    "@tabler/icons-react"
  ],
  "registryDependencies": [
    "https://abackui.com/r/utils.json"
  ],
  "categories": [
    "Forms"
  ],
  "docs": "https://abackui.com/docs/components/dropzone",
  "meta": {
    "status": "beta",
    "usedIn": [
      "aback-tools"
    ]
  },
  "files": [
    {
      "path": "components/ui/dropzone.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { IconUpload } from \"@tabler/icons-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\";\n\nexport interface FileRejection {\n  file: File;\n  reason: FileRejectionReason;\n}\n\n/**\n * True when a file satisfies an `accept` string in the same syntax as the\n * native input: extensions (\".pdf\"), exact MIME types (\"image/png\"), and\n * wildcards (\"image/*\"). The picker filters by `accept`, but dropped files do\n * not, so every file is checked here before it is reported as accepted.\n */\nfunction fileMatchesAccept(file: File, accept?: string) {\n  if (!accept) return true;\n  const tokens = accept\n    .split(\",\")\n    .map((token) => token.trim().toLowerCase())\n    .filter(Boolean);\n  if (tokens.length === 0) return true;\n\n  const name = file.name.toLowerCase();\n  const type = (file.type || \"\").toLowerCase();\n  return tokens.some((token) => {\n    if (token.startsWith(\".\")) return name.endsWith(token);\n    if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n    return type === token;\n  });\n}\n\n/** \"980 B\", \"14.2 KB\", \"3.1 MB\". Uses 1024-based units, like file managers. */\nfunction formatFileSize(bytes: number) {\n  if (!Number.isFinite(bytes) || bytes < 0) return \"\";\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"];\n  let value = bytes / 1024;\n  let unit = 0;\n  while (value >= 1024 && unit < units.length - 1) {\n    value /= 1024;\n    unit += 1;\n  }\n  return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;\n}\n\nexport interface DropzoneProps\n  extends Omit<React.ComponentProps<\"div\">, \"onDrop\" | \"onChange\" | \"children\"> {\n  /** Called with the files that passed type, size, and count checks. */\n  onFilesAccepted: (files: File[]) => void;\n  /** Called with each refused file and the reason, so the page can explain it. */\n  onFilesRejected?: (rejections: FileRejection[]) => void;\n  /** Accepted types, in native `accept` syntax. Checked for dropped files too. */\n  accept?: string;\n  multiple?: boolean;\n  /** Largest accepted file in bytes. */\n  maxSize?: number;\n  /** Most files accepted from one selection. Defaults to 1 unless `multiple`. */\n  maxFiles?: number;\n  disabled?: boolean;\n  /** Primary line, also the input's accessible name. */\n  label?: React.ReactNode;\n  /** Secondary line under the label. */\n  description?: React.ReactNode;\n  /** Formats and limits, such as \"PNG or JPG, up to 10 MB\". Announced as the description. */\n  hint?: React.ReactNode;\n  icon?: React.ReactNode;\n  /** Props for the underlying file input, such as `id`, `name`, or `aria-describedby`. */\n  inputProps?: Omit<\n    React.ComponentProps<\"input\">,\n    \"type\" | \"accept\" | \"multiple\" | \"disabled\" | \"onChange\"\n  >;\n}\n\n/**\n * A drag-and-drop area backed by a real file input. The input is visually\n * hidden but stays focusable, so Tab reaches it, Enter or Space opens the\n * picker, and the frame shows the focus ring. Files are validated the same way\n * whether they were dropped or picked.\n */\nfunction Dropzone({\n  onFilesAccepted,\n  onFilesRejected,\n  accept,\n  multiple = false,\n  maxSize,\n  maxFiles,\n  disabled = false,\n  label = \"Drop a file here\",\n  description = \"or click to browse\",\n  hint,\n  icon,\n  inputProps,\n  className,\n  ...props\n}: DropzoneProps) {\n  const generatedId = React.useId();\n  const inputId = inputProps?.id ?? `${generatedId}-input`;\n  const labelId = `${generatedId}-label`;\n  const descriptionId = `${generatedId}-description`;\n  const hintId = `${generatedId}-hint`;\n  const [dragging, setDragging] = React.useState(false);\n  const depth = React.useRef(0);\n\n  const limit = maxFiles ?? (multiple ? Number.POSITIVE_INFINITY : 1);\n\n  function handleFiles(list: FileList | File[] | null) {\n    if (!list || disabled) return;\n    const files = Array.from(list);\n    const accepted: File[] = [];\n    const rejected: FileRejection[] = [];\n\n    for (const file of files) {\n      if (!fileMatchesAccept(file, accept)) {\n        rejected.push({ file, reason: \"type\" });\n      } else if (maxSize !== undefined && file.size > maxSize) {\n        rejected.push({ file, reason: \"size\" });\n      } else if (accepted.length >= limit) {\n        rejected.push({ file, reason: \"count\" });\n      } else {\n        accepted.push(file);\n      }\n    }\n\n    if (accepted.length > 0) onFilesAccepted(accepted);\n    if (rejected.length > 0) onFilesRejected?.(rejected);\n  }\n\n  function handleDragEnter(event: React.DragEvent<HTMLDivElement>) {\n    event.preventDefault();\n    if (disabled) return;\n    depth.current += 1;\n    setDragging(true);\n  }\n\n  function handleDragOver(event: React.DragEvent<HTMLDivElement>) {\n    event.preventDefault();\n    if (disabled) return;\n    event.dataTransfer.dropEffect = \"copy\";\n  }\n\n  function handleDragLeave(event: React.DragEvent<HTMLDivElement>) {\n    event.preventDefault();\n    depth.current = Math.max(0, depth.current - 1);\n    if (depth.current === 0) setDragging(false);\n  }\n\n  function handleDrop(event: React.DragEvent<HTMLDivElement>) {\n    event.preventDefault();\n    depth.current = 0;\n    setDragging(false);\n    handleFiles(event.dataTransfer.files);\n  }\n\n  const describedBy = [\n    description ? descriptionId : null,\n    hint ? hintId : null,\n    inputProps?.[\"aria-describedby\"],\n  ]\n    .filter(Boolean)\n    .join(\" \");\n\n  return (\n    <div\n      data-slot=\"dropzone\"\n      data-dragging={dragging ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      onDragEnter={handleDragEnter}\n      onDragOver={handleDragOver}\n      onDragLeave={handleDragLeave}\n      onDrop={handleDrop}\n      className={cn(\n        \"relative border-2 border-dashed border-muted-foreground/25 bg-background transition-colors duration-200\",\n        \"has-[input:focus-visible]:border-solid has-[input:focus-visible]:border-focus-ring\",\n        \"has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-focus-ring has-[input:focus-visible]:ring-offset-2 has-[input:focus-visible]:ring-offset-background\",\n        !disabled && \"hover:border-primary-300 hover:bg-muted/50 dark:hover:border-primary-600\",\n        dragging && \"border-primary-500 bg-brand-wash\",\n        disabled && \"cursor-not-allowed opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      <input\n        {...inputProps}\n        id={inputId}\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        aria-labelledby={labelId}\n        aria-describedby={describedBy || undefined}\n        className={cn(\"sr-only\", inputProps?.className)}\n        onChange={(event) => {\n          handleFiles(event.target.files);\n          // Clear the value so choosing the same file again fires a change.\n          event.target.value = \"\";\n        }}\n      />\n      <label\n        htmlFor={inputId}\n        className={cn(\n          \"flex flex-col items-center gap-3 p-8 text-center\",\n          disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        )}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"flex size-12 items-center justify-center bg-brand-chip text-brand-icon [&_svg]:size-6\"\n        >\n          {icon ?? <IconUpload />}\n        </span>\n        <span className=\"flex flex-col gap-1\">\n          <span id={labelId} className=\"text-sm font-medium text-foreground\">\n            {label}\n          </span>\n          {description ? (\n            <span id={descriptionId} className=\"text-sm text-muted-foreground\">\n              {description}\n            </span>\n          ) : null}\n        </span>\n        {hint ? (\n          <span id={hintId} className=\"text-xs text-muted-foreground\">\n            {hint}\n          </span>\n        ) : null}\n      </label>\n    </div>\n  );\n}\n\nexport { Dropzone, fileMatchesAccept, formatFileSize };\n",
      "type": "registry:ui",
      "target": "components/ui/dropzone.tsx"
    }
  ]
}
