{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "type": "registry:ui",
  "title": "File Dropzone",
  "description": "Drag-and-drop or browse for files, with per-file validation, thumbnails, upload progress and retries.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/file-dropzone.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport type DropzoneFileStatus = \"ready\" | \"uploading\" | \"done\" | \"error\";\n\nexport interface DropzoneFile {\n  id: string;\n  file: File;\n  status: DropzoneFileStatus;\n  /** Upload progress from 0 to 1. */\n  progress: number;\n  /** Validation or upload error message. */\n  error?: string;\n  /** True when validation failed: the file was never accepted and is not uploaded. */\n  rejected?: boolean;\n  /** Object URL for image thumbnails (revoked by the component on remove/unmount). */\n  preview?: string;\n}\n\nexport type UploadHandler = (\n  file: File,\n  reportProgress: (fraction: number) => void,\n  signal: AbortSignal,\n) => Promise<void>;\n\nexport interface FileDropzoneHandle {\n  /** Opens the native file picker. */\n  open: () => void;\n  /** Adds files programmatically (validated like dropped files). */\n  addFiles: (files: File[] | FileList) => void;\n  /** Removes every file, aborting uploads in flight. */\n  clear: () => void;\n}\n\nexport interface FileDropzoneProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onChange\"> {\n  /** Same syntax as <input accept>: \".pdf,image/*\". Empty accepts any type. */\n  accept?: string;\n  /** Maximum size per file in bytes. 0 means no limit. */\n  maxSize?: number;\n  /** Maximum number of accepted files. */\n  maxFiles?: number;\n  /** Controlled file list. Pair with onFilesChange; you may set progress/status yourself. */\n  files?: DropzoneFile[];\n  /** Called with the full list after every add, remove and progress update. */\n  onFilesChange?: (files: DropzoneFile[]) => void;\n  /** Uploads one file. Report progress (0–1), resolve when done, reject with an Error to fail. */\n  onUpload?: UploadHandler;\n  /** Show thumbnails for image files. */\n  previews?: boolean;\n  /** Main call to action inside the zone. */\n  label?: string;\n  size?: \"sm\" | \"md\";\n  /** Drag highlight, progress and focus colour (any CSS colour). */\n  accent?: string;\n  disabled?: boolean;\n  /** id for the file input (generated when omitted). */\n  inputId?: string;\n}\n\n/* -------------------------------- Helpers -------------------------------- */\n\nexport function formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\"];\n  let n = bytes / 1024;\n  let u = 0;\n  while (n >= 1024 && u < units.length - 1) {\n    n /= 1024;\n    u++;\n  }\n  return `${n >= 10 ? Math.round(n) : n.toFixed(1).replace(/\\.0$/, \"\")} ${units[u]}`;\n}\n\nconst parseAccept = (accept: string) =>\n  accept\n    .split(\",\")\n    .map((t) => t.trim().toLowerCase())\n    .filter(Boolean);\n\nfunction matchesAccept(name: string, type: string, tokens: string[]) {\n  if (!tokens.length) return true;\n  const lowerName = name.toLowerCase();\n  const lowerType = type.toLowerCase();\n  return tokens.some((t) =>\n    t.startsWith(\".\") ? lowerName.endsWith(t) : t.endsWith(\"/*\") ? lowerType.startsWith(t.slice(0, -1)) : lowerType === t,\n  );\n}\n\nfunction describeAccept(tokens: string[]) {\n  if (!tokens.length) return \"Any file type\";\n  return tokens\n    .map((t) => (t.startsWith(\".\") ? t.slice(1).toUpperCase() : t.endsWith(\"/*\") ? `${t[0].toUpperCase()}${t.slice(1, -2)}s` : (t.split(\"/\")[1] ?? t).toUpperCase()))\n    .join(\", \");\n}\n\nconst extensionOf = (name: string) => {\n  const dot = name.lastIndexOf(\".\");\n  return dot > 0 ? name.slice(dot + 1, dot + 5).toUpperCase() : \"FILE\";\n};\n\nconst sameFile = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;\n\n/* -------------------------------------------------------------------------- */\n\n/**\n * Drag-and-drop or click-to-browse file picker built on a real <input type=\"file\">. Files are\n * validated against accept / maxSize / maxFiles with per-file messages, listed with icons or\n * thumbnails, and uploaded through an optional onUpload(file, reportProgress, signal) callback —\n * or driven entirely by a controlled `files` list. Summaries are announced in a live region.\n */\nexport const FileDropzone = React.forwardRef<FileDropzoneHandle, FileDropzoneProps>(function FileDropzone(\n  {\n    accept = \"\",\n    maxSize = 5 * 1024 * 1024,\n    maxFiles = 5,\n    files,\n    onFilesChange,\n    onUpload,\n    previews = true,\n    label = \"Drop files here\",\n    size = \"md\",\n    accent = \"#FF7448\",\n    disabled = false,\n    inputId: inputIdProp,\n    className = \"\",\n    style,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const inputId = inputIdProp ?? `dz-${uid}`;\n  const titleId = `${inputId}-title`;\n  const rulesId = `${inputId}-rules`;\n\n  const [inner, setInner] = React.useState<DropzoneFile[]>([]);\n  const entries = files ?? inner;\n  // Source of truth for async updates (progress callbacks outlive renders).\n  const entriesRef = React.useRef<DropzoneFile[]>(files ?? []);\n  React.useLayoutEffect(() => {\n    if (files) entriesRef.current = files;\n  }, [files]);\n\n  const [drag, setDrag] = React.useState<\"idle\" | \"accept\" | \"reject\">(\"idle\");\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const dragDepth = React.useRef(0);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const counter = React.useRef(0);\n  const controllers = React.useRef(new Map<string, AbortController>());\n  const objectUrls = React.useRef(new Set<string>());\n  const removeButtons = React.useRef(new Map<string, HTMLButtonElement>());\n\n  // Abort uploads and release thumbnails on unmount.\n  React.useEffect(() => {\n    const ctrls = controllers.current;\n    const urls = objectUrls.current;\n    return () => {\n      ctrls.forEach((c) => c.abort());\n      ctrls.clear();\n      urls.forEach((u) => URL.revokeObjectURL(u));\n      urls.clear();\n    };\n  }, []);\n\n  const tokens = parseAccept(accept);\n  const limit = Math.max(1, maxFiles);\n  const acceptedCount = entries.filter((e) => !e.rejected).length;\n  const totalBytes = entries.reduce((sum, e) => (e.rejected ? sum : sum + e.file.size), 0);\n  const full = acceptedCount >= limit;\n\n  const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));\n\n  const commit = (next: DropzoneFile[]) => {\n    entriesRef.current = next;\n    if (files === undefined) setInner(next);\n    onFilesChange?.(next);\n  };\n\n  const patch = (id: string, change: Partial<DropzoneFile>) => {\n    if (!entriesRef.current.some((e) => e.id === id)) return;\n    commit(entriesRef.current.map((e) => (e.id === id ? { ...e, ...change } : e)));\n  };\n\n  const startUpload = (entry: DropzoneFile) => {\n    if (!onUpload) return;\n    const controller = new AbortController();\n    controllers.current.set(entry.id, controller);\n    const report = (fraction: number) => {\n      if (!controller.signal.aborted) patch(entry.id, { progress: Math.min(1, Math.max(0, fraction)) });\n    };\n    onUpload(entry.file, report, controller.signal).then(\n      () => {\n        if (controller.signal.aborted) return;\n        patch(entry.id, { status: \"done\", progress: 1 });\n        announce(`${entry.file.name} uploaded.`);\n      },\n      (reason: unknown) => {\n        if (controller.signal.aborted) return;\n        const message = reason instanceof Error && reason.message ? reason.message : \"Upload failed\";\n        patch(entry.id, { status: \"error\", error: message });\n        announce(`${entry.file.name} failed to upload: ${message}.`);\n      },\n    ).finally(() => {\n      if (controllers.current.get(entry.id) === controller) controllers.current.delete(entry.id);\n    });\n  };\n\n  const addFiles = (incoming: File[] | FileList) => {\n    const list = Array.from(incoming);\n    if (!list.length || disabled) return;\n    const current = entriesRef.current;\n    let accepted = current.filter((e) => !e.rejected).length;\n    const added: DropzoneFile[] = [];\n    const rejected: DropzoneFile[] = [];\n\n    for (const file of list) {\n      let error: string | undefined;\n      if (!matchesAccept(file.name, file.type, tokens)) error = \"File type not accepted\";\n      else if (maxSize > 0 && file.size > maxSize) error = `Larger than ${formatBytes(maxSize)}`;\n      else if ([...current, ...added].some((e) => !e.rejected && sameFile(e.file, file))) error = \"Already added\";\n      else if (accepted >= limit) error = `Limit of ${limit} ${limit === 1 ? \"file\" : \"files\"} reached`;\n\n      let preview: string | undefined;\n      if (!error && previews && file.type.startsWith(\"image/\")) {\n        preview = URL.createObjectURL(file);\n        objectUrls.current.add(preview);\n      }\n      const entry: DropzoneFile = {\n        id: `${uid}-${++counter.current}`,\n        file,\n        status: error ? \"error\" : onUpload ? \"uploading\" : \"ready\",\n        progress: 0,\n        error,\n        rejected: Boolean(error),\n        preview,\n      };\n      if (error) rejected.push(entry);\n      else {\n        accepted++;\n        added.push(entry);\n      }\n    }\n\n    // Accepted files are listed before this batch's rejections.\n    commit([...current, ...added, ...rejected]);\n    added.forEach(startUpload);\n\n    const parts: string[] = [];\n    if (added.length) parts.push(`${added.length} ${added.length === 1 ? \"file\" : \"files\"} added${onUpload ? \", uploading\" : \"\"}.`);\n    if (rejected.length)\n      parts.push(\n        `${rejected.length} ${rejected.length === 1 ? \"file\" : \"files\"} rejected: ${rejected.map((r) => `${r.file.name}, ${r.error}`).join(\"; \")}.`,\n      );\n    announce(parts.join(\" \"));\n  };\n\n  const releaseEntry = (entry: DropzoneFile) => {\n    controllers.current.get(entry.id)?.abort();\n    controllers.current.delete(entry.id);\n    if (entry.preview) {\n      URL.revokeObjectURL(entry.preview);\n      objectUrls.current.delete(entry.preview);\n    }\n  };\n\n  const remove = (id: string) => {\n    const list = entriesRef.current;\n    const index = list.findIndex((e) => e.id === id);\n    const entry = list[index];\n    if (!entry) return;\n    releaseEntry(entry);\n    const next = list.filter((e) => e.id !== id);\n    commit(next);\n    announce(`${entry.status === \"uploading\" ? \"Cancelled\" : \"Removed\"} ${entry.file.name}.`);\n    // Keep keyboard focus in the list: next item, previous item, or the picker.\n    const target = next[index] ?? next[index - 1];\n    requestAnimationFrame(() => {\n      const el = target ? removeButtons.current.get(target.id) : null;\n      (el ?? inputRef.current)?.focus();\n    });\n  };\n\n  const retry = (id: string) => {\n    const entry = entriesRef.current.find((e) => e.id === id);\n    if (!entry || entry.rejected || !onUpload) return;\n    patch(id, { status: \"uploading\", progress: 0, error: undefined });\n    startUpload(entry);\n  };\n\n  const clear = () => {\n    entriesRef.current.forEach(releaseEntry);\n    commit([]);\n    announce(\"All files removed.\");\n  };\n\n  React.useImperativeHandle(ref, () => ({ open: () => inputRef.current?.click(), addFiles, clear }));\n\n  /* ---------------------------- Drag and drop ---------------------------- */\n\n  const evaluateDrag = (data: DataTransfer): \"accept\" | \"reject\" => {\n    if (disabled || full) return \"reject\";\n    const types = Array.from(data.items)\n      .filter((i) => i.kind === \"file\")\n      .map((i) => i.type);\n    // File names are hidden until drop and MIME types can be empty, so only a definite\n    // MIME mismatch rejects early; everything else is validated per file on drop.\n    if (!tokens.length || tokens.some((t) => t.startsWith(\".\")) || types.some((t) => !t)) return \"accept\";\n    return types.some((t) => matchesAccept(\"\", t, tokens)) ? \"accept\" : \"reject\";\n  };\n\n  const dragHandlers = {\n    onDragEnter: (e: React.DragEvent) => {\n      if (!e.dataTransfer.types.includes(\"Files\")) return;\n      e.preventDefault();\n      dragDepth.current++;\n      if (dragDepth.current === 1) setDrag(evaluateDrag(e.dataTransfer));\n    },\n    onDragOver: (e: React.DragEvent) => {\n      if (!e.dataTransfer.types.includes(\"Files\")) return;\n      e.preventDefault();\n      e.dataTransfer.dropEffect = drag === \"reject\" ? \"none\" : \"copy\";\n    },\n    onDragLeave: () => {\n      dragDepth.current = Math.max(0, dragDepth.current - 1);\n      if (dragDepth.current === 0) setDrag(\"idle\");\n    },\n    onDrop: (e: React.DragEvent) => {\n      if (!e.dataTransfer.types.includes(\"Files\")) return;\n      e.preventDefault();\n      dragDepth.current = 0;\n      setDrag(\"idle\");\n      addFiles(e.dataTransfer.files);\n    },\n  };\n\n  const rules = [describeAccept(tokens), maxSize > 0 ? `up to ${formatBytes(maxSize)} each` : null, `max ${limit} ${limit === 1 ? \"file\" : \"files\"}`]\n    .filter(Boolean)\n    .join(\" · \");\n  const sm = size === \"sm\";\n  const title = drag === \"accept\" ? \"Release to add files\" : drag === \"reject\" ? (full ? \"File limit reached\" : \"These files can't be added\") : label;\n\n  return (\n    <div className={`w-full ${className}`} style={{ [\"--dz-accent\" as string]: accent, ...style }} {...props}>\n      <label\n        htmlFor={inputId}\n        {...dragHandlers}\n        data-state={drag}\n        className={`group relative flex flex-col items-center justify-center rounded-2xl text-center transition-colors duration-200 motion-reduce:transition-none has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_srgb,var(--dz-accent)_30%,transparent)] ${\n          sm ? \"gap-2 px-4 py-6\" : \"gap-3 px-6 py-10\"\n        } ${\n          drag === \"accept\"\n            ? \"bg-[color-mix(in_srgb,var(--dz-accent)_9%,transparent)] text-[var(--dz-accent)]\"\n            : drag === \"reject\"\n              ? \"bg-rose-500/[0.07] text-rose-500\"\n              : \"bg-zinc-900/[0.02] text-zinc-900/25 hover:bg-zinc-900/[0.04] hover:text-zinc-900/40 dark:bg-white/[0.02] dark:text-white/20 dark:hover:bg-white/[0.04] dark:hover:text-white/35\"\n        } ${disabled ? \"pointer-events-none opacity-50\" : \"cursor-pointer\"}`}\n      >\n        {/* Dashed border drawn in SVG so it can march while files hover over the zone. */}\n        <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 size-full overflow-visible\">\n          <motion.rect\n            x=\"0.75\"\n            y=\"0.75\"\n            rx=\"15\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={drag === \"idle\" ? 1.5 : 2}\n            strokeDasharray=\"7 6\"\n            style={{ width: \"calc(100% - 1.5px)\", height: \"calc(100% - 1.5px)\" }}\n            animate={drag !== \"idle\" && !reduceMotion ? { strokeDashoffset: [0, -26] } : { strokeDashoffset: 0 }}\n            transition={drag !== \"idle\" && !reduceMotion ? { duration: 0.9, ease: \"linear\", repeat: Infinity } : { duration: 0 }}\n          />\n        </svg>\n\n        <input\n          ref={inputRef}\n          id={inputId}\n          type=\"file\"\n          multiple={limit > 1}\n          accept={accept || undefined}\n          disabled={disabled}\n          aria-labelledby={titleId}\n          aria-describedby={rulesId}\n          onChange={(e) => {\n            if (e.target.files) addFiles(e.target.files);\n            e.target.value = \"\"; // allow picking the same file again\n          }}\n          className=\"sr-only\"\n        />\n\n        <motion.span\n          aria-hidden=\"true\"\n          animate={reduceMotion ? undefined : { y: drag === \"accept\" ? -4 : 0, scale: drag === \"accept\" ? 1.06 : 1 }}\n          transition={{ type: \"spring\", stiffness: 400, damping: 24 }}\n          className={`grid place-items-center rounded-xl border bg-white text-zinc-700 shadow-[0_1px_0_rgba(0,0,0,0.04)] dark:bg-zinc-900 dark:text-zinc-200 ${\n            sm ? \"size-9\" : \"size-11\"\n          } ${drag === \"reject\" ? \"border-rose-500/40\" : drag === \"accept\" ? \"border-[var(--dz-accent)]\" : \"border-zinc-900/10 dark:border-white/10\"}`}\n        >\n          <svg viewBox=\"0 0 20 20\" className=\"size-5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <path d=\"M10 13V3.5M6.25 7.25 10 3.5l3.75 3.75\" />\n            <path d=\"M3.5 12.5v2.25c0 .97.78 1.75 1.75 1.75h9.5c.97 0 1.75-.78 1.75-1.75V12.5\" />\n          </svg>\n        </motion.span>\n\n        <span className=\"flex flex-col gap-1\">\n          <span id={titleId} className={`font-medium tracking-[-0.01em] text-zinc-950 dark:text-zinc-50 ${sm ? \"text-[14px]\" : \"text-[15px]\"}`}>\n            {title}\n            {drag === \"idle\" && (\n              <span className=\"font-normal text-zinc-500 dark:text-zinc-400\">\n                {\" \"}\n                or{\" \"}\n                <span className=\"text-zinc-950 underline decoration-[var(--dz-accent)] decoration-2 underline-offset-[3px] dark:text-white\">browse</span>\n              </span>\n            )}\n          </span>\n          <span id={rulesId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            {rules}\n          </span>\n        </span>\n      </label>\n\n      {entries.length > 0 && (\n        <ul aria-label=\"Selected files\" className=\"mt-3 flex flex-col gap-2\">\n          <AnimatePresence initial={false}>\n            {entries.map((entry) => {\n              const pct = Math.round(entry.progress * 100);\n              const failed = entry.status === \"error\";\n              return (\n                <motion.li\n                  key={entry.id}\n                  layout={reduceMotion ? false : \"position\"}\n                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={reduceMotion ? { opacity: 0, pointerEvents: \"none\" } : { opacity: 0, scale: 0.96, pointerEvents: \"none\", transition: { duration: 0.16 } }}\n                  transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 34 }}\n                  className={`flex items-center gap-3 rounded-xl border bg-white p-2.5 dark:bg-zinc-950 ${\n                    failed ? \"border-rose-500/30 dark:border-rose-400/30\" : \"border-zinc-900/10 dark:border-white/10\"\n                  }`}\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"relative grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg border border-zinc-900/10 bg-zinc-900/[0.03] dark:border-white/10 dark:bg-white/[0.04]\"\n                  >\n                    {entry.preview ? (\n                      // eslint-disable-next-line @next/next/no-img-element -- object URL thumbnail; the shipped file must not depend on next/image\n                      <img src={entry.preview} alt=\"\" className=\"size-full object-cover\" />\n                    ) : (\n                      <>\n                        <svg viewBox=\"0 0 24 24\" className=\"size-6 text-zinc-400 dark:text-zinc-500\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinejoin=\"round\">\n                          <path d=\"M6 2.75h8.25L19 7.5v13.75H6z\" />\n                          <path d=\"M14 2.75V7.75h5\" />\n                        </svg>\n                        <span className=\"absolute bottom-1 font-mono text-[7px] font-semibold tracking-[0.06em] text-zinc-600 dark:text-zinc-300\">\n                          {extensionOf(entry.file.name)}\n                        </span>\n                      </>\n                    )}\n                  </span>\n\n                  <div className=\"min-w-0 flex-1\">\n                    <div className=\"flex items-baseline justify-between gap-3\">\n                      <span className=\"truncate text-[13px] font-medium text-zinc-900 dark:text-zinc-100\">{entry.file.name}</span>\n                      <span className=\"shrink-0 font-mono text-[10px] tabular-nums tracking-[0.06em] text-zinc-500 dark:text-zinc-400\">\n                        {formatBytes(entry.file.size)}\n                      </span>\n                    </div>\n                    <div className=\"mt-1.5 flex h-4 items-center gap-2.5\">\n                      {entry.status === \"uploading\" && (\n                        <>\n                          <span\n                            role=\"progressbar\"\n                            aria-label={`Uploading ${entry.file.name}`}\n                            aria-valuemin={0}\n                            aria-valuemax={100}\n                            aria-valuenow={pct}\n                            className=\"relative h-1 flex-1 overflow-hidden rounded-full bg-zinc-900/[0.08] dark:bg-white/[0.1]\"\n                          >\n                            <span\n                              className=\"absolute inset-0 origin-left rounded-full bg-[var(--dz-accent)] transition-[scale] duration-200 ease-out motion-reduce:transition-none\"\n                              style={{ scale: `${entry.progress} 1` }}\n                            />\n                          </span>\n                          <span className=\"w-9 text-right font-mono text-[10px] tabular-nums text-zinc-500 dark:text-zinc-400\">{pct}%</span>\n                        </>\n                      )}\n                      {entry.status === \"done\" && (\n                        <span className=\"flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-emerald-600 dark:text-emerald-400\">\n                          <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                            <path d=\"M2.5 6.2 5 8.5l4.5-5\" />\n                          </svg>\n                          Uploaded\n                        </span>\n                      )}\n                      {entry.status === \"ready\" && (\n                        <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">Ready</span>\n                      )}\n                      {failed && (\n                        <span className=\"flex min-w-0 items-center gap-2 text-[12px] font-medium text-rose-600 dark:text-rose-400\">\n                          <span className=\"truncate\">{entry.error}</span>\n                          {!entry.rejected && onUpload && (\n                            <button\n                              type=\"button\"\n                              onClick={() => retry(entry.id)}\n                              className=\"shrink-0 rounded font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-900 underline decoration-[var(--dz-accent)] underline-offset-2 outline-none focus-visible:ring-2 focus-visible:ring-[var(--dz-accent)] dark:text-white\"\n                            >\n                              Retry<span className=\"sr-only\"> {entry.file.name}</span>\n                            </button>\n                          )}\n                        </span>\n                      )}\n                    </div>\n                  </div>\n\n                  <button\n                    ref={(el) => {\n                      if (el) removeButtons.current.set(entry.id, el);\n                      else removeButtons.current.delete(entry.id);\n                    }}\n                    type=\"button\"\n                    onClick={() => remove(entry.id)}\n                    aria-label={`${entry.status === \"uploading\" ? \"Cancel upload of\" : \"Remove\"} ${entry.file.name}`}\n                    className=\"grid size-8 shrink-0 place-items-center rounded-lg text-zinc-500 outline-none transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--dz-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n                  >\n                    <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n                      <path d=\"M3 3l6 6M9 3l-6 6\" />\n                    </svg>\n                  </button>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </ul>\n      )}\n\n      {entries.length > 0 && (\n        <div className=\"mt-2.5 flex items-center justify-between gap-3 px-1\">\n          <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            {acceptedCount} of {limit} {limit === 1 ? \"file\" : \"files\"} · {formatBytes(totalBytes)}\n          </span>\n          <button\n            type=\"button\"\n            onClick={clear}\n            className=\"rounded font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 outline-none transition-colors hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--dz-accent)] dark:text-zinc-400 dark:hover:text-white\"\n          >\n            Clear all\n          </button>\n        </div>\n      )}\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </div>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/file-dropzone"
}
