{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "type": "registry:ui",
  "title": "File dropzone",
  "description": "A React drag and drop upload zone with accept and reject states, type and size checks, image previews, per file progress, retry and screen reader updates.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/file-dropzone.tsx",
      "type": "registry:ui",
      "target": "components/ui/file-dropzone.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { ArrowUpIcon, CheckIcon, RotateCwIcon, XIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * GOLD STANDARD COMPONENT.\n * A drop target with four designed states (rest, hover, drag-accept,\n * drag-reject), a hairline dashed border drawn in SVG so the dashes stay crisp\n * and can march while dragging, and a file list with progress, error, retry.\n */\n\ntype UploadStatus = \"uploading\" | \"done\" | \"error\"\n\ntype UploadFile = {\n  id: string\n  file: File\n  progress: number\n  status: UploadStatus\n  error?: string\n  /** Failed validation (type/size), so retry makes no sense. */\n  invalid?: boolean\n  preview?: string\n}\n\ntype FileDropzoneProps = {\n  /** Called with accepted files every time files are added. */\n  onFiles?: (files: File[]) => void\n  /**\n   * Optional uploader. Call onProgress(0..1) as bytes go out; throw to fail.\n   * Without it, files are listed as done immediately.\n   */\n  upload?: (file: File, onProgress: (p: number) => void) => Promise<void>\n  accept?: string\n  multiple?: boolean\n  /** Bytes. */\n  maxSize?: number\n  maxFiles?: number\n  disabled?: boolean\n  /** Show the file list under the zone. */\n  showList?: boolean\n  label?: string\n  hint?: string\n  className?: string\n}\n\nconst fmt = (b: number) =>\n  b < 1024 ? `${b} B` : b < 1024 ** 2 ? `${(b / 1024).toFixed(0)} KB` : `${(b / 1024 ** 2).toFixed(1)} MB`\n\nfunction matches(accept: string | undefined, type: string, name = \"\") {\n  if (!accept) return true\n  return accept.split(\",\").some((raw) => {\n    const a = raw.trim().toLowerCase()\n    if (!a) return false\n    if (a.startsWith(\".\")) return name.toLowerCase().endsWith(a)\n    if (a.endsWith(\"/*\")) return type.toLowerCase().startsWith(a.slice(0, -1))\n    return type.toLowerCase() === a\n  })\n}\n\nfunction describeAccept(accept?: string) {\n  if (!accept) return null\n  return accept\n    .split(\",\")\n    .map((a) => a.trim().replace(/^\\./, \"\").replace(\"image/*\", \"images\").replace(/^.*\\//, \"\").toUpperCase())\n    .join(\", \")\n}\n\nfunction extOf(name: string) {\n  const e = name.split(\".\").pop()\n  return e && e !== name ? e.slice(0, 4).toUpperCase() : \"FILE\"\n}\n\nfunction FileDropzone({\n  onFiles,\n  upload,\n  accept,\n  multiple = true,\n  maxSize,\n  maxFiles,\n  disabled,\n  showList = true,\n  label = \"Drop files to upload\",\n  hint,\n  className,\n}: FileDropzoneProps) {\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const depth = React.useRef(0)\n  const [drag, setDrag] = React.useState<\"idle\" | \"accept\" | \"reject\">(\"idle\")\n  const [files, setFiles] = React.useState<UploadFile[]>([])\n  const [announce, setAnnounce] = React.useState(\"\")\n  const id = React.useId()\n  const hintText =\n    hint ??\n    [describeAccept(accept), maxSize ? `up to ${fmt(maxSize)}` : null].filter(Boolean).join(\" · \") ??\n    \"\"\n\n  // Revoke object URLs on unmount.\n  const filesRef = React.useRef(files)\n  filesRef.current = files\n  React.useEffect(() => () => filesRef.current.forEach((f) => f.preview && URL.revokeObjectURL(f.preview)), [])\n\n  const patch = (fid: string, p: Partial<UploadFile>) =>\n    setFiles((list) => list.map((f) => (f.id === fid ? { ...f, ...p } : f)))\n\n  const run = React.useCallback(\n    async (u: UploadFile) => {\n      if (!upload) return patch(u.id, { progress: 1, status: \"done\" })\n      patch(u.id, { status: \"uploading\", progress: 0, error: undefined })\n      try {\n        await upload(u.file, (p) => patch(u.id, { progress: Math.max(0, Math.min(1, p)) }))\n        patch(u.id, { progress: 1, status: \"done\" })\n      } catch (err) {\n        patch(u.id, { status: \"error\", error: err instanceof Error ? err.message : \"Upload failed\" })\n      }\n    },\n    [upload]\n  )\n\n  const add = (list: FileList | File[]) => {\n    const incoming = Array.from(list).slice(0, multiple ? undefined : 1)\n    const room = maxFiles ? Math.max(0, maxFiles - files.length) : incoming.length\n    const next: UploadFile[] = incoming.slice(0, room).map((file) => {\n      const bad = !matches(accept, file.type, file.name)\n        ? `${extOf(file.name)} files aren't accepted`\n        : maxSize && file.size > maxSize\n          ? `Larger than ${fmt(maxSize)}`\n          : undefined\n      return {\n        id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 7)}`,\n        file,\n        progress: 0,\n        status: bad ? \"error\" : \"uploading\",\n        error: bad,\n        invalid: !!bad,\n        preview: file.type.startsWith(\"image/\") ? URL.createObjectURL(file) : undefined,\n      }\n    })\n    if (!next.length) return\n    setFiles((l) => (multiple ? [...l, ...next] : next))\n    const ok = next.filter((f) => !f.error)\n    if (ok.length) onFiles?.(ok.map((f) => f.file))\n    setAnnounce(`${next.length} file${next.length > 1 ? \"s\" : \"\"} added`)\n    ok.forEach(run)\n  }\n\n  const remove = (fid: string) =>\n    setFiles((l) => {\n      const f = l.find((x) => x.id === fid)\n      if (f?.preview) URL.revokeObjectURL(f.preview)\n      return l.filter((x) => x.id !== fid)\n    })\n\n  const open = () => !disabled && inputRef.current?.click()\n\n  const dragKind = (e: React.DragEvent): \"accept\" | \"reject\" => {\n    const items = Array.from(e.dataTransfer.items ?? []).filter((i) => i.kind === \"file\")\n    if (!items.length) return \"accept\"\n    if (!multiple && items.length > 1) return \"reject\"\n    return items.every((i) => !i.type || matches(accept, i.type)) ? \"accept\" : \"reject\"\n  }\n\n  const over = drag !== \"idle\"\n  const reject = drag === \"reject\"\n\n  return (\n    <div data-slot=\"file-dropzone\" className={cn(\"w-full\", className)}>\n      <input\n        id={id}\n        ref={inputRef}\n        type=\"file\"\n        className=\"sr-only\"\n        tabIndex={-1}\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(e) => {\n          if (e.target.files) add(e.target.files)\n          e.target.value = \"\"\n        }}\n      />\n      <div\n        role=\"button\"\n        tabIndex={disabled ? -1 : 0}\n        aria-disabled={disabled || undefined}\n        aria-describedby={`${id}-hint`}\n        data-drag={drag}\n        onClick={open}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" || e.key === \" \") {\n            e.preventDefault()\n            open()\n          }\n        }}\n        onDragEnter={(e) => {\n          e.preventDefault()\n          if (disabled) return\n          depth.current += 1\n          setDrag(dragKind(e))\n        }}\n        onDragOver={(e) => {\n          e.preventDefault()\n          e.dataTransfer.dropEffect = reject || disabled ? \"none\" : \"copy\"\n        }}\n        onDragLeave={() => {\n          depth.current = Math.max(0, depth.current - 1)\n          if (depth.current === 0) setDrag(\"idle\")\n        }}\n        onDrop={(e) => {\n          e.preventDefault()\n          depth.current = 0\n          setDrag(\"idle\")\n          if (!disabled && e.dataTransfer.files?.length) add(e.dataTransfer.files)\n        }}\n        className={cn(\n          \"group/drop relative isolate flex w-full cursor-pointer flex-col items-center justify-center gap-3 overflow-hidden rounded-xl px-6 py-10 text-center outline-none\",\n          \"bg-surface transition-[background-color] duration-[140ms] ease-hairline\",\n          \"focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg\",\n          !over && \"hover:bg-sunken/60\",\n          over && !reject && \"bg-accent-soft\",\n          reject && \"bg-[color-mix(in_oklch,var(--danger)_6%,var(--surface))]\",\n          disabled && \"pointer-events-none opacity-50\"\n        )}\n      >\n        {/* Hairline dashed border: crisp at any size, marches while dragging. */}\n        <svg aria-hidden className=\"pointer-events-none absolute inset-[0.5px] size-[calc(100%-1px)] overflow-visible\">\n          <rect\n            width=\"100%\"\n            height=\"100%\"\n            rx=\"11.5\"\n            fill=\"none\"\n            strokeWidth=\"1\"\n            strokeDasharray=\"6 5\"\n            className={cn(\n              \"transition-[stroke] duration-[140ms]\",\n              reject ? \"stroke-danger\" : over ? \"stroke-accent\" : \"stroke-border-strong group-hover/drop:stroke-fg-subtle\",\n              over && !reject && \"animate-[dash-march_0.6s_linear_infinite]\"\n            )}\n          />\n        </svg>\n        {/* Soft dot field that appears under the cursor while dragging. */}\n        <div\n          aria-hidden\n          className={cn(\n            \"absolute inset-0 -z-10 bg-dots opacity-0 transition-opacity duration-200 [mask-image:radial-gradient(60%_70%_at_50%_45%,black,transparent)]\",\n            over && \"opacity-100\"\n          )}\n        />\n        <span\n          className={cn(\n            \"grid size-10 place-items-center rounded-[10px] border shadow-key transition-[transform,background-color,border-color,color] duration-200 ease-hairline\",\n            reject\n              ? \"border-danger/40 bg-surface text-danger\"\n              : over\n                ? \"-translate-y-1 border-accent bg-accent text-on-accent shadow-ink\"\n                : \"border-border bg-raised text-fg-muted group-hover/drop:-translate-y-0.5 group-hover/drop:text-fg\"\n          )}\n        >\n          {reject ? <XIcon className=\"size-4\" /> : <ArrowUpIcon className=\"size-4\" />}\n        </span>\n        <div className=\"space-y-1\">\n          <p className=\"text-sm font-medium text-fg\">\n            {reject ? \"That file type won't work\" : over ? \"Release to upload\" : label}\n          </p>\n          <p id={`${id}-hint`} className=\"text-xs text-fg-muted\">\n            {reject ? (\n              describeAccept(accept) ? `Only ${describeAccept(accept)}` : \"Try one file at a time\"\n            ) : (\n              <>\n                <span className=\"font-medium text-fg underline decoration-border-strong underline-offset-[3px] transition-colors group-hover/drop:decoration-fg-subtle\">\n                  Browse\n                </span>{\" \"}\n                or drag here{hintText ? ` · ${hintText}` : \"\"}\n              </>\n            )}\n          </p>\n        </div>\n      </div>\n\n      {showList && files.length > 0 ? (\n        <ul className=\"mt-3 space-y-2\" aria-label=\"Files\">\n          {files.map((f) => (\n            <FileRow key={f.id} f={f} onRemove={() => remove(f.id)} onRetry={f.status === \"error\" && !f.invalid && upload ? () => run(f) : undefined} />\n          ))}\n        </ul>\n      ) : null}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  )\n}\n\nfunction FileRow({ f, onRemove, onRetry }: { f: UploadFile; onRemove: () => void; onRetry?: () => void }) {\n  const pct = Math.round(f.progress * 100)\n  const error = f.status === \"error\"\n  const done = f.status === \"done\"\n  return (\n    <li\n      data-status={f.status}\n      className={cn(\n        \"group/file relative flex animate-[rise-in_220ms_var(--ease-hairline)_both] items-center gap-3 overflow-hidden rounded-xl border bg-surface p-2.5 pr-3 shadow-raised\",\n        error ? \"border-[color-mix(in_oklch,var(--danger)_40%,transparent)]\" : \"border-border\"\n      )}\n    >\n      <span className=\"relative grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg border border-border bg-sunken\">\n        {f.preview ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img src={f.preview} alt=\"\" className=\"size-full object-cover\" />\n        ) : (\n          <span className=\"font-mono text-[9px] font-semibold tracking-[0.04em] text-fg-muted\">{extOf(f.file.name)}</span>\n        )}\n      </span>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-center gap-2\">\n          <p className=\"truncate text-[0.8125rem] font-medium text-fg\">{f.file.name}</p>\n          {done ? (\n            <span className=\"grid size-4 shrink-0 animate-[pop-in_280ms_var(--ease-hairline)_both] place-items-center rounded-full bg-success text-white\">\n              <CheckIcon className=\"size-2.5\" strokeWidth={3} />\n            </span>\n          ) : null}\n        </div>\n        <p className={cn(\"mt-0.5 text-xs tabular-nums\", error ? \"text-danger\" : \"text-fg-muted\")}>\n          {error ? f.error : `${fmt(f.file.size)}${f.status === \"uploading\" ? ` · ${pct}%` : \" · Uploaded\"}`}\n        </p>\n      </div>\n      {onRetry ? (\n        <button\n          type=\"button\"\n          onClick={onRetry}\n          aria-label={`Retry ${f.file.name}`}\n          className=\"grid size-7 place-items-center rounded-md text-fg-muted outline-none hover:bg-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-accent\"\n        >\n          <RotateCwIcon className=\"size-3.5\" />\n        </button>\n      ) : null}\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        aria-label={`Remove ${f.file.name}`}\n        className=\"grid size-7 place-items-center rounded-md text-fg-subtle outline-none hover:bg-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-accent\"\n      >\n        <XIcon className=\"size-3.5\" />\n      </button>\n      {f.status === \"uploading\" ? (\n        <span aria-hidden className=\"absolute inset-x-0 bottom-0 h-0.5 bg-sunken\">\n          <span\n            className=\"block h-full origin-left bg-accent transition-transform duration-200 ease-hairline\"\n            style={{ transform: `scaleX(${f.progress})` }}\n          />\n        </span>\n      ) : null}\n    </li>\n  )\n}\n\nexport { FileDropzone }\nexport type { FileDropzoneProps, UploadFile }\n"
    }
  ]
}
