{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "title": "Dropzone",
  "description": "A file drop area on a native file input that checks type, size and count, and lists the chosen files.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@corsair-ui/utils",
    "@corsair-ui/button"
  ],
  "files": [
    {
      "path": "registry/default/ui/dropzone.tsx",
      "content": "\"use client\";\n\nimport { FileIcon, UploadIcon, XIcon } from \"lucide-react\";\nimport { useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\n\ntype DropzoneRejectionReason = \"type\" | \"size\" | \"count\";\n\ninterface DropzoneRejection {\n  file: File;\n  reason: DropzoneRejectionReason;\n}\n\ninterface DropzoneLabels {\n  /** The text of the drop area. Defaults to \"Drop files here or \" followed by `browse`. */\n  prompt: ReactNode;\n  /** The emphasised last word of the default prompt. */\n  browse: ReactNode;\n  /** A second line under the prompt: accepted types, size limit. */\n  hint: ReactNode;\n  /** Accessible name of the list of chosen files. */\n  files: string;\n  /** Accessible name of a file's remove button. */\n  remove: (name: string) => string;\n  /** Shown when a file does not match `accept`. */\n  rejectedType: (name: string) => string;\n  /** Shown when a file is larger than `maxSize`; gets the formatted limit. */\n  rejectedSize: (name: string, maxSize: string) => string;\n  /** Shown when a file would go over `maxFiles` (1 without `multiple`). */\n  rejectedCount: (name: string, maxFiles: number) => string;\n}\n\nconst DEFAULT_LABELS: Omit<DropzoneLabels, \"prompt\" | \"hint\"> = {\n  browse: \"browse\",\n  files: \"Chosen files\",\n  remove: (name) => `Remove ${name}`,\n  rejectedType: (name) => `${name} is not an accepted file type.`,\n  rejectedSize: (name, maxSize) => `${name} is larger than ${maxSize}.`,\n  rejectedCount: (name, maxFiles) =>\n    maxFiles === 1\n      ? `${name} was not added: only one file is allowed.`\n      : `${name} was not added: up to ${maxFiles} files are allowed.`,\n};\n\n/**\n * Formats a byte count with the locale's kilobyte or megabyte unit\n * (\"340 kB\", \"1.2 MB\"), in decimal units.\n *\n * @example\n * formatFileSize(1_250_000); // \"1.3 MB\"\n */\nfunction formatFileSize(bytes: number, locale?: Intl.LocalesArgument) {\n  const mega = bytes >= 1_000_000;\n  return new Intl.NumberFormat(locale, {\n    style: \"unit\",\n    unit: mega ? \"megabyte\" : \"kilobyte\",\n    unitDisplay: \"short\",\n    maximumFractionDigits: 1,\n  }).format(mega ? bytes / 1_000_000 : bytes / 1_000);\n}\n\n/**\n * Whether a file matches an `accept` string the way the file input reads\n * it: comma-separated extensions (\".pdf\"), MIME types (\"image/png\") and\n * wildcards (\"image/*\"). An empty or missing `accept` matches everything.\n */\nfunction matchesAccept(file: File, accept: string | undefined) {\n  if (!accept?.trim()) return true;\n  const name = file.name.toLowerCase();\n  const type = file.type.toLowerCase();\n  return accept\n    .split(\",\")\n    .map((pattern) => pattern.trim().toLowerCase())\n    .filter(Boolean)\n    .some((pattern) => {\n      if (pattern.startsWith(\".\")) return name.endsWith(pattern);\n      if (pattern.endsWith(\"/*\")) return type.startsWith(pattern.slice(0, -1));\n      return type === pattern;\n    });\n}\n\nfunction sameFile(a: File, b: File) {\n  return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;\n}\n\nfunction fileKey(file: File) {\n  return `${file.name}-${file.size}-${file.lastModified}`;\n}\n\n/**\n * Keeps the input's own FileList in step with the chosen files, so a form\n * submits exactly what is listed. Without DataTransfer it can only be cleared.\n */\nfunction syncInput(input: HTMLInputElement | null, files: File[]) {\n  if (!input) return;\n  if (typeof DataTransfer !== \"undefined\") {\n    try {\n      const transfer = new DataTransfer();\n      for (const file of files) transfer.items.add(file);\n      input.files = transfer.files;\n      return;\n    } catch {\n      // Some browsers cannot build a DataTransfer by hand.\n    }\n  }\n  if (files.length === 0) input.value = \"\";\n}\n\ninterface DropzoneProps extends Omit<ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Accepted files, in the syntax of the input's `accept` attribute. Checked on drop too. */\n  accept?: string;\n  /** Allows more than one file. Without it a new file replaces the current one. */\n  multiple?: boolean;\n  /** Largest accepted file, in bytes. */\n  maxSize?: number;\n  /** Most files kept at once when `multiple` is set. */\n  maxFiles?: number;\n  /** Blocks picking and dropping. */\n  disabled?: boolean;\n  /** Chosen files (controlled). */\n  files?: File[];\n  /** Chosen files at first (uncontrolled). */\n  defaultFiles?: File[];\n  /** Called with the full list of chosen files after a pick, drop or removal. */\n  onFilesChange?: (files: File[]) => void;\n  /** Called with the files that were turned away, and why. */\n  onReject?: (rejections: DropzoneRejection[]) => void;\n  /** Field name, so the chosen files submit with a form. */\n  name?: string;\n  /** Id of the file input, for an external `<label htmlFor>`. */\n  id?: string;\n  /** Makes a file required for form submission. */\n  required?: boolean;\n  /** Locale for the file sizes. Defaults to the browser's. */\n  locale?: Intl.LocalesArgument;\n  /** User-facing text. */\n  labels?: Partial<DropzoneLabels>;\n}\n\n/**\n * An area to drop files on, or to click to pick them, built on a native\n * `<input type=\"file\">`. The input is visually hidden but stays in the Tab\n * order; the drop area is its `<label>`, so Enter, Space or a click open the\n * file picker, and screen readers announce the prompt as the input's name.\n * The area shows the focus ring while the input has keyboard focus and sets\n * `data-dragging` while files are dragged over it (`data-disabled` when\n * disabled). Files are checked against `accept`, `maxSize` and `maxFiles`\n * on both pick and drop; the ones turned away are listed in a `role=\"alert\"`\n * message and passed to `onReject`. Chosen files are listed below with their\n * size and a named remove button; removing one moves focus to the next.\n * With `name`, the input holds the chosen files, so they submit with a form.\n * Only colours transition, and not with reduced motion.\n *\n * @example\n * <Dropzone\n *   name=\"attachments\"\n *   accept=\"image/*,.pdf\"\n *   multiple\n *   maxSize={5_000_000}\n *   labels={{ hint: \"Images or PDF, up to 5 MB\" }}\n *   onFilesChange={setFiles}\n * />\n */\nfunction Dropzone({\n  accept,\n  multiple = false,\n  maxSize,\n  maxFiles,\n  disabled = false,\n  files: filesProp,\n  defaultFiles,\n  onFilesChange,\n  onReject,\n  name,\n  id,\n  required,\n  locale,\n  labels: labelsProp,\n  className,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-describedby\": ariaDescribedBy,\n  ...props\n}: DropzoneProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp };\n  const generatedId = useId();\n  const inputId = id ?? `${generatedId}-input`;\n  const errorId = `${generatedId}-error`;\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n  const areaRef = useRef<HTMLLabelElement>(null);\n\n  const [internalFiles, setInternalFiles] = useState<File[]>(defaultFiles ?? []);\n  const [dragging, setDragging] = useState(false);\n  const [rejections, setRejections] = useState<DropzoneRejection[]>([]);\n  const files = filesProp ?? internalFiles;\n  const limit = multiple ? (maxFiles ?? Infinity) : 1;\n\n  useEffect(() => {\n    syncInput(inputRef.current, files);\n  }, [files]);\n\n  const commit = (next: File[]) => {\n    if (filesProp === undefined) setInternalFiles(next);\n    onFilesChange?.(next);\n    syncInput(inputRef.current, next);\n  };\n\n  const addFiles = (incoming: File[]) => {\n    const rejected: DropzoneRejection[] = [];\n    const valid: File[] = [];\n    for (const file of incoming) {\n      if (!matchesAccept(file, accept)) rejected.push({ file, reason: \"type\" });\n      else if (maxSize !== undefined && file.size > maxSize)\n        rejected.push({ file, reason: \"size\" });\n      else valid.push(file);\n    }\n    // One file at a time: a new pick replaces the current one.\n    let next = multiple ? [...files] : [];\n    for (const file of valid) {\n      if (next.some((existing) => sameFile(existing, file))) continue;\n      if (next.length >= limit) rejected.push({ file, reason: \"count\" });\n      else next = [...next, file];\n    }\n    setRejections(rejected);\n    if (rejected.length) onReject?.(rejected);\n    if (valid.length) commit(next);\n    else syncInput(inputRef.current, files);\n  };\n\n  const removeFile = (index: number) => {\n    // Hand focus to the next remove button, the previous one, or the input.\n    const buttons = listRef.current?.querySelectorAll<HTMLElement>(\"[data-slot=dropzone-remove]\");\n    const target = buttons?.[index + 1] ?? buttons?.[index - 1] ?? inputRef.current;\n    setRejections([]);\n    commit(files.filter((_, position) => position !== index));\n    target?.focus();\n  };\n\n  // The drop listeners read the latest props and files through this ref.\n  const latest = useRef({ disabled, addFiles });\n  useEffect(() => {\n    latest.current = { disabled, addFiles };\n  });\n\n  // Dropping is an extra on top of the input, so the listeners go on the\n  // label natively rather than as JSX handlers on a non-interactive element.\n  useEffect(() => {\n    const area = areaRef.current;\n    if (!area) return;\n    let depth = 0;\n    const hasFiles = (event: DragEvent) =>\n      Array.from(event.dataTransfer?.types ?? []).includes(\"Files\");\n    const onDragEnter = (event: DragEvent) => {\n      if (latest.current.disabled || !hasFiles(event)) return;\n      event.preventDefault();\n      depth += 1;\n      setDragging(true);\n    };\n    const onDragOver = (event: DragEvent) => {\n      if (!hasFiles(event) || !event.dataTransfer) return;\n      event.preventDefault();\n      event.dataTransfer.dropEffect = latest.current.disabled ? \"none\" : \"copy\";\n    };\n    const onDragLeave = () => {\n      depth = Math.max(0, depth - 1);\n      if (depth === 0) setDragging(false);\n    };\n    const onDrop = (event: DragEvent) => {\n      event.preventDefault();\n      depth = 0;\n      setDragging(false);\n      if (latest.current.disabled) return;\n      latest.current.addFiles(Array.from(event.dataTransfer?.files ?? []));\n    };\n    area.addEventListener(\"dragenter\", onDragEnter);\n    area.addEventListener(\"dragover\", onDragOver);\n    area.addEventListener(\"dragleave\", onDragLeave);\n    area.addEventListener(\"drop\", onDrop);\n    return () => {\n      area.removeEventListener(\"dragenter\", onDragEnter);\n      area.removeEventListener(\"dragover\", onDragOver);\n      area.removeEventListener(\"dragleave\", onDragLeave);\n      area.removeEventListener(\"drop\", onDrop);\n    };\n  }, []);\n\n  const state = {\n    \"data-dragging\": dragging ? \"\" : undefined,\n    \"data-disabled\": disabled ? \"\" : undefined,\n  };\n  const describedBy = [ariaDescribedBy, rejections.length ? errorId : undefined]\n    .filter(Boolean)\n    .join(\" \");\n\n  return (\n    <div\n      data-slot=\"dropzone\"\n      {...state}\n      className={cn(\"flex flex-col gap-3\", className)}\n      {...props}\n    >\n      <input\n        ref={inputRef}\n        id={inputId}\n        type=\"file\"\n        name={name}\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        required={required}\n        aria-invalid={ariaInvalid}\n        aria-describedby={describedBy || undefined}\n        data-slot=\"dropzone-input\"\n        className=\"peer sr-only\"\n        onChange={(event) => addFiles(Array.from(event.target.files ?? []))}\n      />\n      <label\n        htmlFor={inputId}\n        data-slot=\"dropzone-area\"\n        ref={areaRef}\n        {...state}\n        className={cn(\n          \"border-input flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-6 text-center text-sm\",\n          \"transition-[color,background-color,border-color,box-shadow] motion-reduce:transition-none\",\n          \"hover:bg-muted/50 data-[dragging]:border-primary data-[dragging]:bg-accent\",\n          \"peer-focus-visible:border-ring peer-focus-visible:ring-ring/50 peer-focus-visible:ring-[3px]\",\n          \"peer-aria-[invalid=true]:border-destructive\",\n          \"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:hover:bg-transparent\"\n        )}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full\"\n        >\n          <UploadIcon className=\"size-5\" />\n        </span>\n        <span data-slot=\"dropzone-prompt\" className=\"font-medium\">\n          {labels.prompt ?? (\n            <>\n              Drop files here or{\" \"}\n              <span className=\"text-primary underline underline-offset-4\">{labels.browse}</span>\n            </>\n          )}\n        </span>\n        {labels.hint ? (\n          <span data-slot=\"dropzone-hint\" className=\"text-muted-foreground text-xs\">\n            {labels.hint}\n          </span>\n        ) : null}\n      </label>\n      {rejections.length ? (\n        <div\n          id={errorId}\n          role=\"alert\"\n          data-slot=\"dropzone-error\"\n          className=\"text-destructive flex flex-col gap-1 text-sm\"\n        >\n          {rejections.map(({ file, reason }, index) => (\n            <p key={`${fileKey(file)}-${index}`} data-reason={reason}>\n              {reason === \"type\"\n                ? labels.rejectedType(file.name)\n                : reason === \"size\"\n                  ? labels.rejectedSize(file.name, formatFileSize(maxSize ?? 0, locale))\n                  : labels.rejectedCount(file.name, limit)}\n            </p>\n          ))}\n        </div>\n      ) : null}\n      {files.length ? (\n        <ul\n          ref={listRef}\n          data-slot=\"dropzone-files\"\n          aria-label={labels.files}\n          className=\"flex flex-col gap-2\"\n        >\n          {files.map((file, index) => (\n            <li\n              key={fileKey(file)}\n              data-slot=\"dropzone-file\"\n              className=\"bg-card flex items-center gap-3 rounded-md border px-3 py-2 text-sm\"\n            >\n              <FileIcon aria-hidden=\"true\" className=\"text-muted-foreground size-4 shrink-0\" />\n              <span className=\"min-w-0 flex-1 truncate\">{file.name}</span>\n              <span data-slot=\"dropzone-file-size\" className=\"text-muted-foreground shrink-0\">\n                {formatFileSize(file.size, locale)}\n              </span>\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                data-slot=\"dropzone-remove\"\n                aria-label={labels.remove(file.name)}\n                disabled={disabled}\n                onClick={() => removeFile(index)}\n              >\n                <XIcon aria-hidden=\"true\" />\n              </Button>\n            </li>\n          ))}\n        </ul>\n      ) : null}\n    </div>\n  );\n}\n\nexport {\n  Dropzone,\n  formatFileSize,\n  matchesAccept,\n  type DropzoneLabels,\n  type DropzoneProps,\n  type DropzoneRejection,\n  type DropzoneRejectionReason,\n};\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}