{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "type": "registry:ui",
  "title": "File dropzone",
  "description": "A generous target for dropping one or more files.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/file-dropzone/file-dropzone.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { DragEvent, KeyboardEvent } from \"react\";\nimport {\n  ArrowUp,\n  CircleAlert,\n  File as FileIcon,\n  FileArchive,\n  FileImage,\n  FilePlay,\n  FileText,\n  RotateCw,\n  X,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionTemplate,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport type { HTMLMotionProps, TargetAndTransition, Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type FileDropzoneStatus = \"uploading\" | \"uploaded\" | \"failed\";\n/** One row in the file list. A row without a status is a plain selection. */\n/** `preview` is a thumbnail URL; image files added by the visitor get one automatically. */\nexport type FileDropzoneItem = {\n  id: string;\n  name: string;\n  size: number;\n  status?: FileDropzoneStatus;\n  progress?: number;\n  error?: string;\n  retryable?: boolean;\n  file?: File;\n  preview?: string;\n};\n/** Report 0 to 100 through onProgress, resolve when the file lands, or reject with an Error whose message becomes the row's reason. Removing the row aborts the signal. */\nexport type FileDropzoneUpload = (\n  item: FileDropzoneItem,\n  options: { onProgress: (percent: number) => void; signal: AbortSignal },\n) => Promise<void>;\n\nexport type FileDropzoneProps = {\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  onFilesChange?: (files: File[]) => void;\n  label?: string;\n  description?: string;\n  /** Rows present at mount, such as files uploaded earlier. They render in place without an entrance. */\n  defaultItems?: FileDropzoneItem[];\n  /** Uploads each added file. Without it the list shows plain selections. */\n  onUpload?: FileDropzoneUpload;\n  /** Files above this many bytes fail with a size reason and never upload. */\n  maxSize?: number;\n  /** Render the file list under the drop target or inside its edge. */\n  listPlacement?: \"below\" | \"inside\";\n  /** Replaces the small line under the description. */\n  note?: string;\n  /** Label while files hover over the target. */\n  dropLabel?: string;\n  /** Once the list holds this many files, the prompt folds to a slim bar so the list has room. A file dragged over brings the icon back. */\n  compactAt?: number;\n};\n\nconst MB = 1024 * 1024;\nconst PERIOD = 7;\nconst enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\nconst instant: Transition = { duration: 0 };\nconst fade: Transition = { duration: motionTokens.duration.instant };\n/** Exits are shorter than entries: the same critically damped spring, settled sooner. */\nconst collapse: Transition = { ...motionTokens.spring.smooth, visualDuration: 0.3 };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", filter: \"blur(0px)\" };\nconst clamp = (value: number) => Math.min(Math.max(value, 0), 100);\n/** A short, decaying side-to-side shake: the refusal reads as \"no\" without moving anything else. */\nconst SHAKE = { x: [0, -7, 6, -4, 3, -1.5, 0] };\nconst shakeTransition: Transition = { duration: 0.42, ease: \"easeOut\" };\n/** New rows drop out of the target into the list, with a touch of life as they land. */\nconst land: Transition = { type: \"spring\", visualDuration: 0.42, bounce: 0 };\n/** Quick press, spring release. Neither row button anchors a floating layer. */\nconst rowButton =\n  \"cursor-pointer [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),scale_var(--duration-spring)_var(--ease-spring)] active:scale-97 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-none\";\n\nexport function formatFileSize(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < MB) return `${Math.round(bytes / 1024)} KB`;\n  const mb = bytes / MB;\n  return `${mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1))} MB`;\n}\n\nfunction TypeIcon({ name }: { name: string }) {\n  const props = { size: 20, strokeWidth: 1.75, \"aria-hidden\": true } as const;\n  const extension = name.toLowerCase().split(\".\").pop() ?? \"\";\n  if ([\"png\", \"jpg\", \"jpeg\", \"gif\", \"webp\", \"svg\", \"avif\", \"heic\"].includes(extension)) return <FileImage {...props} />;\n  if ([\"mov\", \"mp4\", \"webm\", \"m4v\", \"avi\"].includes(extension)) return <FilePlay {...props} />;\n  if ([\"zip\", \"gz\", \"tar\", \"rar\", \"7z\"].includes(extension)) return <FileArchive {...props} />;\n  if ([\"pdf\", \"md\", \"txt\", \"doc\", \"docx\", \"rtf\"].includes(extension)) return <FileText {...props} />;\n  return <FileIcon {...props} />;\n}\n\n/** Outgoing copies are hidden from assistive tech while they leave, so only the current text is read. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\n/** The new text rises into place and unblurs while the old one lifts away, in the same slot. */\nfunction TextSwap({ text, className, reduce }: { text: string; className?: string; reduce: boolean }) {\n  return (\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <Swap\n        key={text}\n        className={className}\n        initial={reduce ? { opacity: 0 } : textIn}\n        animate={shown}\n        exit={reduce ? { opacity: 0, transition: fade } : textOut}\n        transition={reduce ? fade : enter}\n      >\n        {text}\n      </Swap>\n    </AnimatePresence>\n  );\n}\n\n/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */\nfunction MotionText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const words = text.split(\" \");\n  return (\n    <>\n      <span className=\"sr-only\">{text}</span>\n      <span className=\"relative block\" aria-hidden=\"true\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {words.map((word, index) => (\n            <motion.span\n              key={`${index}:${word}`}\n              className=\"inline-block whitespace-pre\"\n              initial={\n                reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : { opacity: 0, y: \"-0.35em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }\n              }\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.instant }\n                  : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n              }\n            >\n              {index < words.length - 1 ? `${word} ` : word}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </>\n  );\n}\n\n/** The error row opens on a spring and follows the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction ErrorRow({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const copyRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = copyRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return (\n    <motion.div\n      className=\"overflow-hidden\"\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n      }\n    >\n      <motion.div\n        ref={copyRef}\n        className=\"flex items-start gap-1.5 pt-3 text-sm leading-body text-red\"\n        role=\"alert\"\n        initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n      >\n        {/* The icon sits on the first line's optical center, so a wrapped message keeps it at the top. */}\n        <CircleAlert className=\"mt-[3px] flex-none\" size={14} strokeWidth={2.25} aria-hidden=\"true\" />\n        <span className=\"relative min-w-0 flex-1\">\n          <MotionText text={text} />\n        </span>\n      </motion.div>\n    </motion.div>\n  );\n}\n\n/** The circle closes, then the tick draws through it. */\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg\n      className=\"flex-none text-green\"\n      width={14}\n      height={14}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.25}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <motion.path\n        d=\"M12 2.5a9.5 9.5 0 1 1 0 19a9.5 9.5 0 1 1 0-19\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: motionTokens.duration.considered * 0.7, ease: [...motionTokens.ease.enter] }}\n      />\n      <motion.path\n        d=\"m8.2 12.4 2.6 2.6 5-5.2\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ ...enter, delay: 0.16 }}\n      />\n    </svg>\n  );\n}\n\ntype RowProps = {\n  item: FileDropzoneItem;\n  reduce: boolean;\n  delay: number;\n  fresh: boolean;\n  canRetry: boolean;\n  onRemove: () => void;\n  onRetry: () => void;\n  removeRef: (node: HTMLButtonElement | null) => void;\n};\n\nfunction Thumb({ item }: { item: FileDropzoneItem }) {\n  const [broken, setBroken] = useState(false);\n  if (!item.preview || broken)\n    return (\n      <span className=\"grid size-10 place-items-center text-ink-2 @max-[300px]/dropzone-list:hidden\">\n        <TypeIcon name={item.name} />\n      </span>\n    );\n  return (\n    <span className=\"block size-10 overflow-hidden rounded-card bg-hover shadow-[inset_0_0_0_1px_oklch(0%_0_0/.06)] @max-[300px]/dropzone-list:hidden\">\n      {/* eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic, and previews are often object URLs */}\n      <img\n        className=\"block size-full object-cover\"\n        src={item.preview}\n        alt=\"\"\n        width={40}\n        height={40}\n        decoding=\"async\"\n        onError={() => setBroken(true)}\n      />\n    </span>\n  );\n}\n\nfunction FileRow({ item, reduce, delay, fresh, canRetry, onRemove, onRetry, removeRef }: RowProps) {\n  const { status } = item;\n  const rowRef = useRef<HTMLDivElement>(null);\n  // A failure shakes the row once: a file refused on arrival after it lands, an upload that fails at the moment it fails.\n  const shakenFor = useRef<FileDropzoneStatus | \"new\" | undefined>(fresh ? \"new\" : status);\n  useEffect(() => {\n    const previous = shakenFor.current;\n    if (status === previous) return;\n    shakenFor.current = status;\n    const node = rowRef.current;\n    if (status !== \"failed\" || reduce || !node) return;\n    animate(node, SHAKE, { ...shakeTransition, delay: previous === \"new\" ? delay + 0.3 : 0 });\n  }, [status, reduce, delay]);\n  // One spring drives the bar and the counted percentage, so both always agree.\n  const progress = useMotionValue(status === \"uploaded\" ? 100 : (item.progress ?? 0));\n  const x = useTransform(progress, (value) => `${clamp(value) - 100}%`);\n  const percent = useTransform(progress, (value) => `${Math.round(clamp(value))}%`);\n  // A finished upload keeps its bar until the spring reaches the end, so 100% is seen before the label turns to Uploaded.\n  const [filled, setFilled] = useState(status !== \"uploading\");\n  const [seen, setSeen] = useState(status);\n  if (seen !== status) {\n    setSeen(status);\n    if (status === \"uploading\") setFilled(false);\n  }\n  const phase = status === \"uploaded\" && !filled && !reduce ? \"uploading\" : status;\n  const target = status === \"uploaded\" ? 100 : (item.progress ?? 0);\n  useEffect(() => {\n    if (status !== \"uploading\" && status !== \"uploaded\") return;\n    // Every upload starts from an empty bar, even a retry that failed halfway.\n    if (reduce || (status === \"uploading\" && target === 0)) {\n      progress.jump(target);\n      return;\n    }\n    const controls = animate(progress, target, {\n      ...motionTokens.spring.smooth,\n      onComplete: status === \"uploaded\" ? () => setFilled(true) : undefined,\n    });\n    return () => controls.stop();\n  }, [status, target, progress, reduce]);\n  // The label turns as soon as the count reads 100%, without waiting out the spring's last fraction of a pixel.\n  useMotionValueEvent(progress, \"change\", (value) => {\n    if (status === \"uploaded\" && value >= 99.5) setFilled(true);\n  });\n  const failed = status === \"failed\";\n  const retry = failed && canRetry && item.retryable !== false;\n  return (\n    <motion.li\n      className=\"min-w-0 overflow-hidden\"\n      initial={reduce ? { opacity: 0 } : { height: 0 }}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: fade }\n          : { height: 0, opacity: 0, transition: { height: collapse, opacity: { ...exitFast, delay: 0.04 } } }\n      }\n      transition={reduce ? fade : { height: { ...motionTokens.spring.smooth, delay }, opacity: fade }}\n    >\n      {/* New rows rise a little and unblur while their height opens, so the rows below make room instead of jumping. */}\n      <motion.div\n        ref={rowRef}\n        data-row=\"\"\n        className={cn(\n          \"relative mt-2 grid min-h-[58px] grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-x-3 rounded-control border bg-surface py-2 pr-2 pl-[9px] transition-[border-color] duration-standard ease-standard motion-reduce:transition-none @max-[300px]/dropzone-list:grid-cols-[minmax(0,1fr)_auto] @max-[300px]/dropzone-list:pl-3.5\",\n          failed ? \"border-[color-mix(in_oklab,var(--red)_26%,var(--line))]\" : \"border-line\",\n        )}\n        initial={reduce ? false : { opacity: 0, y: -22, scale: 0.94, filter: `blur(${motionTokens.blur.soft}px)` }}\n        animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n        exit={reduce ? undefined : { scale: 0.97, filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }}\n        transition={\n          reduce\n            ? instant\n            : {\n                y: { ...land, delay },\n                scale: { ...land, delay },\n                opacity: { ...enter, delay },\n                filter: { ...enter, delay },\n              }\n        }\n      >\n        <Thumb item={item} />\n        <span className=\"grid min-w-0\">\n          <span className=\"truncate text-sm leading-body font-medium\" title={item.name}>\n            {item.name}\n          </span>\n          <span className=\"mt-px flex min-w-0 items-center text-xs leading-body whitespace-nowrap text-ink-3 tabular-nums\">\n            <span>{formatFileSize(item.size)}</span>\n            {/* The outgoing status is measured against the phase box when it pops out to leave. */}\n            {phase && (\n              <>\n                <span className=\"px-1.5\" aria-hidden=\"true\">\n                  ·\n                </span>\n                <span className=\"relative flex min-w-0 flex-1\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <Swap\n                      key={phase}\n                      className={cn(\n                        \"inline-flex min-w-0 items-center gap-[5px]\",\n                        phase === \"uploaded\" && \"text-ink-2\",\n                        phase === \"failed\" && \"text-red\",\n                      )}\n                      initial={reduce ? { opacity: 0 } : textIn}\n                      animate={shown}\n                      exit={reduce ? { opacity: 0, transition: fade } : textOut}\n                      transition={reduce ? fade : enter}\n                    >\n                      {phase === \"uploading\" ? (\n                        <span>\n                          Uploading <motion.span className=\"tabular-nums\">{percent}</motion.span>\n                        </span>\n                      ) : phase === \"uploaded\" ? (\n                        <>\n                          <DrawnCheck reduce={reduce} />\n                          <span>Uploaded</span>\n                        </>\n                      ) : (\n                        <>\n                          <CircleAlert className=\"flex-none\" size={14} strokeWidth={2.25} aria-hidden=\"true\" />\n                          <span className=\"min-w-0 overflow-hidden text-ellipsis\" title={item.error}>\n                            <span className=\"sr-only\">Failed: </span>\n                            {item.error || \"Upload failed\"}\n                          </span>\n                        </>\n                      )}\n                    </Swap>\n                  </AnimatePresence>\n                </span>\n              </>\n            )}\n          </span>\n          {/* The bar opens with the upload, fills on a spring, then folds away just after the check draws. */}\n          <AnimatePresence initial={false}>\n            {phase === \"uploading\" && (\n              <motion.span\n                key=\"bar\"\n                className=\"block overflow-hidden\"\n                initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={\n                  reduce\n                    ? { opacity: 0, transition: fade }\n                    : {\n                        height: 0,\n                        opacity: 0,\n                        transition: { height: { ...collapse, delay: 0.22 }, opacity: { ...exitFast, delay: 0.16 } },\n                      }\n                }\n                transition={reduce ? fade : { height: motionTokens.spring.smooth, opacity: enter }}\n              >\n                {/* The fill slides in from the left instead of scaling, so its rounded end keeps its shape at every value. */}\n                <span\n                  className=\"mt-[7px] mb-0.5 block h-1 overflow-hidden rounded-full bg-line\"\n                  role=\"progressbar\"\n                  aria-label={`Uploading ${item.name}`}\n                  aria-valuemin={0}\n                  aria-valuemax={100}\n                  aria-valuenow={Math.round(clamp(target))}\n                >\n                  <motion.span className=\"block size-full rounded-[inherit] bg-accent\" style={{ x }} />\n                </span>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n        <span className=\"flex items-center\">\n          {/* Retry opens its own width, so the name column narrows on a spring instead of losing space in one frame. */}\n          <AnimatePresence initial={false}>\n            {retry && (\n              <motion.span\n                key=\"retry\"\n                className=\"flex overflow-hidden\"\n                initial={reduce ? { opacity: 0 } : { width: 0, opacity: 0 }}\n                animate={{ width: \"auto\", opacity: 1 }}\n                exit={\n                  reduce\n                    ? { opacity: 0, transition: fade }\n                    : {\n                        width: 0,\n                        opacity: 0,\n                        transition: { width: collapse, opacity: { duration: motionTokens.duration.instant } },\n                      }\n                }\n                transition={reduce ? fade : { width: motionTokens.spring.smooth, opacity: enter }}\n              >\n                <motion.span\n                  className=\"flex py-[3px] pr-1.5 pl-[3px]\"\n                  initial={reduce ? false : { scale: 0.8, filter: `blur(${motionTokens.blur.subtle}px)` }}\n                  animate={{ scale: 1, filter: \"blur(0px)\" }}\n                  transition={reduce ? instant : motionTokens.spring.snappy}\n                >\n                  {/* On narrow lists Retry keeps only its icon, so the failure reason keeps the room to be read in full. */}\n                  <button\n                    type=\"button\"\n                    data-retry=\"\"\n                    className={cn(\n                      rowButton,\n                      \"flex h-[30px] items-center gap-[5px] rounded-button border border-line bg-surface pr-3 pl-2.5 text-xs/[inherit] font-medium tracking-body whitespace-nowrap text-ink hover:border-line-strong hover:bg-hover @max-[440px]/dropzone-list:w-[30px] @max-[440px]/dropzone-list:justify-center @max-[440px]/dropzone-list:p-0\",\n                    )}\n                    onClick={onRetry}\n                    aria-label={`Retry ${item.name}`}\n                    title=\"Retry\"\n                  >\n                    <RotateCw size={14} strokeWidth={2} aria-hidden=\"true\" />\n                    <span className=\"@max-[440px]/dropzone-list:hidden\">Retry</span>\n                  </button>\n                </motion.span>\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <button\n            ref={removeRef}\n            type=\"button\"\n            data-remove=\"\"\n            className={cn(\n              rowButton,\n              \"grid size-8 place-items-center rounded-button border-0 bg-transparent text-ink-2 hover:bg-hover hover:text-ink\",\n            )}\n            onClick={onRemove}\n            aria-label={`Remove ${item.name}`}\n            title=\"Remove\"\n          >\n            <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          </button>\n        </span>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nconst light =\n  \"pointer-events-none absolute inset-0 rounded-[inherit] transition-opacity duration-standard ease-standard motion-reduce:transition-none\";\n/** Three sheets of paper, the target's one illustration. Each sheet sets --r, --x and --y; they fan open on hover and spread wider under a file. */\nconst sheet =\n  \"absolute inset-0 grid origin-bottom items-end justify-items-center rounded-[7px] border bg-surface pb-[5px] shadow-[0_1px_2px_oklch(0%_0_0/.05),0_3px_8px_oklch(0%_0_0/.04)] [transform:translate(var(--x),var(--y))_rotate(var(--r))] [transition:transform_var(--duration-spring)_var(--ease-spring),color_var(--duration-standard)_var(--ease-standard),border-color_var(--duration-standard)_var(--ease-standard)] motion-reduce:transition-none before:absolute before:top-2 before:left-[7px] before:h-0.5 before:w-4 before:rounded-[2px] before:bg-current before:opacity-30 before:content-[''] after:absolute after:top-[13px] after:left-[7px] after:h-0.5 after:w-[11px] after:rounded-[2px] after:bg-current after:opacity-30 after:content-['']\";\n\nexport function FileDropzone({\n  accept,\n  multiple = true,\n  maxFiles = 5,\n  onFilesChange,\n  label = \"Add files\",\n  description = \"Drop files here or choose from your device\",\n  defaultItems,\n  onUpload,\n  maxSize,\n  listPlacement = \"below\",\n  note,\n  dropLabel,\n  compactAt,\n}: FileDropzoneProps) {\n  const [items, setItems] = useState<FileDropzoneItem[]>(() => defaultItems ?? []);\n  const [dragging, setDragging] = useState(false);\n  const [error, setError] = useState(\"\");\n  const [announcement, setAnnouncement] = useState(\"\");\n  // Index of the first row in the latest batch, so only that batch staggers in.\n  const [batchStart, setBatchStart] = useState(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const dropRef = useRef<HTMLButtonElement>(null);\n  const zoneRef = useRef<HTMLDivElement>(null);\n  const edgeRef = useRef<SVGRectElement>(null);\n  const dragDepth = useRef(0);\n  const nextId = useRef(0);\n  const controllers = useRef(new Map<string, AbortController>());\n  const removeRefs = useRef(new Map<string, HTMLButtonElement>());\n  const descriptionId = useId();\n  const noteId = useId();\n  const reduce = !!useReducedMotion();\n  const inside = listPlacement === \"inside\";\n  const compact = compactAt !== undefined && items.length >= compactAt;\n  // Rows the visitor added in this session: only these fly in and shake when refused.\n  const [freshIds, setFreshIds] = useState<ReadonlySet<string>>(() => new Set());\n  // Thumbnails made here are object URLs, released when their row goes or the component unmounts.\n  const ownedPreviews = useRef(new Map<string, string>());\n  const [hovered, setHovered] = useState(false);\n  const [focusWithin, setFocusWithin] = useState(false);\n\n  useEffect(() => {\n    const active = controllers.current;\n    const previews = ownedPreviews.current;\n    return () => {\n      active.forEach((controller) => controller.abort());\n      previews.forEach((url) => URL.revokeObjectURL(url));\n    };\n  }, []);\n\n  /* The drag glow: a soft light on the edge and a faint wash that follow the pointer on a spring, so the outline leans toward the file. */\n  const pointerX = useMotionValue(50),\n    pointerY = useMotionValue(50);\n  const glowSpring = { stiffness: 260, damping: 32, mass: 0.8 };\n  const glowX = useSpring(pointerX, glowSpring),\n    glowY = useSpring(pointerY, glowSpring);\n  const edgeLight = useMotionTemplate`radial-gradient(180px circle at ${glowX}% ${glowY}%, var(--accent), transparent 70%)`;\n  const washLight = useMotionTemplate`radial-gradient(260px circle at ${glowX}% ${glowY}%, color-mix(in oklab, var(--accent) 6%, transparent), transparent 70%)`;\n  const track = (event: DragEvent) => {\n    const zone = zoneRef.current;\n    if (!zone) return;\n    const box = zone.getBoundingClientRect();\n    const x = ((event.clientX - box.left) / Math.max(1, box.width)) * 100,\n      y = ((event.clientY - box.top) / Math.max(1, box.height)) * 100;\n    if (reduce || !dragging) {\n      pointerX.jump(x);\n      pointerY.jump(y);\n      glowX.jump(x);\n      glowY.jump(y);\n    } else {\n      pointerX.set(x);\n      pointerY.set(y);\n    }\n  };\n\n  const shakeZone = () => {\n    if (!reduce && zoneRef.current) animate(zoneRef.current, SHAKE, shakeTransition);\n  };\n\n  // The dashed edge is drawn, not bordered, so its dashes can close into a solid line. Its length is fitted to the\n  // measured perimeter, so every dash is the same size and the seam never shows, at any width or list height.\n  useLayoutEffect(() => {\n    const zone = zoneRef.current,\n      edge = edgeRef.current;\n    if (!zone || !edge) return;\n    const fit = () => {\n      const width = zone.offsetWidth - 1,\n        height = zone.offsetHeight - 1;\n      const radius = Math.max(\n        0,\n        Math.min(parseFloat(getComputedStyle(zone).borderTopLeftRadius) - 0.5, width / 2, height / 2),\n      );\n      const perimeter = 2 * (width + height) - 8 * radius + 2 * Math.PI * radius;\n      edge.setAttribute(\"rx\", String(radius));\n      edge.setAttribute(\"pathLength\", String(Math.max(8, Math.round(perimeter / PERIOD)) * PERIOD));\n    };\n    fit();\n    // A folding prompt also eases its corner radius, so the edge follows every frame of that transition.\n    let frame = 0;\n    const follow = () => {\n      fit();\n      frame = requestAnimationFrame(follow);\n    };\n    const onRun = (event: TransitionEvent) => {\n      if (event.target === zone && event.propertyName.includes(\"radius\")) {\n        cancelAnimationFrame(frame);\n        follow();\n      }\n    };\n    const onEnd = (event: TransitionEvent) => {\n      if (event.target === zone && event.propertyName.includes(\"radius\")) {\n        cancelAnimationFrame(frame);\n        fit();\n      }\n    };\n    zone.addEventListener(\"transitionrun\", onRun);\n    zone.addEventListener(\"transitionend\", onEnd);\n    zone.addEventListener(\"transitioncancel\", onEnd);\n    const observer = typeof ResizeObserver === \"undefined\" ? undefined : new ResizeObserver(fit);\n    observer?.observe(zone);\n    return () => {\n      observer?.disconnect();\n      cancelAnimationFrame(frame);\n      zone.removeEventListener(\"transitionrun\", onRun);\n      zone.removeEventListener(\"transitionend\", onEnd);\n      zone.removeEventListener(\"transitioncancel\", onEnd);\n    };\n  }, []);\n\n  const filesOf = (list: FileDropzoneItem[]) => list.flatMap((item) => (item.file ? [item.file] : []));\n  const patch = (id: string, next: (item: FileDropzoneItem) => FileDropzoneItem) =>\n    setItems((current) => current.map((item) => (item.id === id ? next(item) : item)));\n\n  function startUpload(item: FileDropzoneItem) {\n    if (!onUpload) return;\n    controllers.current.get(item.id)?.abort();\n    const controller = new AbortController();\n    controllers.current.set(item.id, controller);\n    patch(item.id, (current) => ({ ...current, status: \"uploading\", progress: 0, error: undefined }));\n    onUpload(\n      { ...item, status: \"uploading\", progress: 0, error: undefined },\n      {\n        signal: controller.signal,\n        onProgress: (percent) => {\n          if (!controller.signal.aborted)\n            patch(item.id, (current) =>\n              current.status === \"uploading\"\n                ? { ...current, progress: Math.max(current.progress ?? 0, clamp(percent)) }\n                : current,\n            );\n        },\n      },\n    )\n      .then(\n        () => {\n          if (controller.signal.aborted) return;\n          patch(item.id, (current) => ({ ...current, status: \"uploaded\", progress: 100 }));\n          setAnnouncement(`${item.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(item.id, (current) => ({ ...current, status: \"failed\", error: message, retryable: true }));\n          setAnnouncement(`${item.name} failed. ${message}`);\n        },\n      )\n      .finally(() => {\n        if (controllers.current.get(item.id) === controller) controllers.current.delete(item.id);\n      });\n  }\n\n  function addFiles(incoming: FileList | File[]) {\n    const list = Array.from(incoming);\n    const accepted =\n      accept\n        ?.split(\",\")\n        .map((value) => value.trim().toLowerCase())\n        .filter(Boolean) ?? [];\n    const matching = list.filter(\n      (file) =>\n        accepted.length === 0 ||\n        accepted.some((type) =>\n          type.startsWith(\".\")\n            ? file.name.toLowerCase().endsWith(type)\n            : type.endsWith(\"/*\")\n              ? file.type.startsWith(type.slice(0, -1))\n              : file.type === type,\n        ),\n    );\n    const kept = multiple ? items : [];\n    const same = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;\n    const fresh = matching.filter(\n      (file, index) =>\n        matching.findIndex((other) => same(other, file)) === index &&\n        !kept.some((item) => item.file && same(item.file, file)),\n    );\n    const room = multiple ? Math.max(0, maxFiles - kept.length) : 1;\n    const added = fresh.slice(0, room).map((file): FileDropzoneItem => {\n      const tooLarge = maxSize !== undefined && file.size > maxSize;\n      const id = `${file.name}-${file.size}-${file.lastModified}-${nextId.current++}`;\n      const preview =\n        file.type.startsWith(\"image/\") && typeof URL.createObjectURL === \"function\"\n          ? URL.createObjectURL(file)\n          : undefined;\n      if (preview) ownedPreviews.current.set(id, preview);\n      return {\n        id,\n        name: file.name,\n        size: file.size,\n        file,\n        preview,\n        status: tooLarge ? \"failed\" : onUpload ? \"uploading\" : undefined,\n        progress: 0,\n        error: tooLarge ? `File is larger than ${formatFileSize(maxSize)}` : undefined,\n        retryable: !tooLarge,\n      };\n    });\n    const rejected = matching.length !== list.length;\n    const overflow = fresh.length > added.length;\n    setError(\n      rejected\n        ? list.length - matching.length === 1 && list.length === 1\n          ? `${list[0]!.name} is not an accepted file type.`\n          : \"Some files were not added because their type is not accepted.\"\n        : overflow\n          ? `You can add up to ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}.`\n          : \"\",\n    );\n    if (rejected || overflow) shakeZone();\n    if (!added.length) return;\n    if (!multiple) {\n      controllers.current.forEach((controller) => controller.abort());\n      controllers.current.clear();\n    }\n    const next = [...kept, ...added];\n    setFreshIds((current) => new Set([...current, ...added.map((item) => item.id)]));\n    setBatchStart(kept.length);\n    setItems((current) => (multiple ? [...current, ...added] : added));\n    onFilesChange?.(filesOf(next));\n    const failed = added.filter((item) => item.status === \"failed\");\n    setAnnouncement(\n      `${added.length} ${added.length === 1 ? \"file\" : \"files\"} added.${failed.length ? ` ${failed.map((item) => `${item.name}: ${item.error}`).join(\". \")}.` : \"\"}`,\n    );\n    added.filter((item) => item.status === \"uploading\").forEach(startUpload);\n  }\n\n  // Focus stays in the list: the next row's remove button, else the previous one, else the drop target.\n  function removeItem(target: FileDropzoneItem) {\n    controllers.current.get(target.id)?.abort();\n    controllers.current.delete(target.id);\n    const index = items.findIndex((item) => item.id === target.id);\n    const neighbor = items[index + 1] ?? items[index - 1];\n    const next = items.filter((item) => item.id !== target.id);\n    const preview = ownedPreviews.current.get(target.id);\n    // The row keeps its thumbnail while it closes, then the URL is released.\n    if (preview) {\n      ownedPreviews.current.delete(target.id);\n      window.setTimeout(() => URL.revokeObjectURL(preview), 600);\n    }\n    setItems((current) => current.filter((item) => item.id !== target.id));\n    setError(\"\");\n    setAnnouncement(`${target.name} removed`);\n    if (target.file) onFilesChange?.(filesOf(next));\n    requestAnimationFrame(() => (neighbor ? removeRefs.current.get(neighbor.id) : dropRef.current)?.focus());\n  }\n\n  // The retry button leaves with the failure, so focus moves to the row's remove button.\n  function retryItem(item: FileDropzoneItem) {\n    setAnnouncement(`Retrying ${item.name}`);\n    startUpload(item);\n    requestAnimationFrame(() => removeRefs.current.get(item.id)?.focus());\n  }\n\n  // Up and down move between rows, keeping the same action. Delete or Backspace removes the focused row.\n  function moveFocus(event: KeyboardEvent<HTMLUListElement>) {\n    if (event.key === \"Delete\" || event.key === \"Backspace\") {\n      const row = (event.target as HTMLElement).closest<HTMLElement>(\"[data-row]\");\n      const remove = row?.querySelector<HTMLButtonElement>(\"[data-remove]\");\n      if (remove) {\n        event.preventDefault();\n        remove.click();\n      }\n      return;\n    }\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return;\n    const button = (event.target as HTMLElement).closest(\"button\");\n    const rows = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(\"[data-row]\"));\n    const row = button?.closest<HTMLElement>(\"[data-row]\");\n    if (!button || !row) return;\n    const nextRow = rows[rows.indexOf(row) + (event.key === \"ArrowDown\" ? 1 : -1)];\n    if (!nextRow) return;\n    event.preventDefault();\n    (\n      nextRow.querySelector<HTMLButtonElement>(button.hasAttribute(\"data-retry\") ? \"[data-retry]\" : \"[data-remove]\") ??\n      nextRow.querySelector<HTMLButtonElement>(\"[data-remove]\")\n    )?.focus();\n  }\n\n  // Paste works while the pointer is over the target or focus is inside it, so a screenshot on the clipboard is one keystroke away.\n  const pasteArmed = hovered || focusWithin;\n  const addRef = useRef(addFiles);\n  useLayoutEffect(() => {\n    addRef.current = addFiles;\n  });\n  useEffect(() => {\n    if (!pasteArmed) return;\n    const onPaste = (event: ClipboardEvent) => {\n      const files = event.clipboardData?.files;\n      if (!files?.length) return;\n      const target = event.target as HTMLElement | null;\n      if (target?.closest(\"input:not([type=file]), textarea, [contenteditable='true']\")) return;\n      event.preventDefault();\n      addRef.current(files);\n    };\n    document.addEventListener(\"paste\", onPaste);\n    return () => document.removeEventListener(\"paste\", onPaste);\n  }, [pasteArmed]);\n\n  const carriesFiles = (event: DragEvent) => Array.from(event.dataTransfer?.types ?? []).includes(\"Files\");\n  const dropCopy = dropLabel ?? (onUpload ? \"Drop to upload\" : multiple ? \"Drop to add files\" : \"Drop to add the file\");\n  const noteCopy = note ?? (accept ? `Accepted: ${accept}` : `Up to ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`);\n  // Slots open and fold their own height, so the prompt changes shape on a spring instead of in one frame.\n  const slot = {\n    initial: reduce ? { opacity: 0 } : { height: 0, opacity: 0 },\n    animate: { height: \"auto\", opacity: 1 },\n    exit: reduce\n      ? { opacity: 0, transition: fade }\n      : { height: 0, opacity: 0, transition: { height: collapse, opacity: exitFast } },\n    transition: reduce ? fade : { height: motionTokens.spring.smooth, opacity: enter },\n  };\n  const rowsInside = inside && items.length > 0;\n\n  // The list's own padding opens with the first row, so the gap under the target never appears in one frame.\n  const list = (\n    <motion.ul\n      className={cn(\"@container/dropzone-list m-0 grid min-w-0 list-none p-0\", inside && \"px-3\")}\n      aria-label=\"Files\"\n      aria-hidden={items.length ? undefined : true}\n      onKeyDown={moveFocus}\n      initial={false}\n      animate={{ paddingTop: items.length && !inside ? 4 : 0, paddingBottom: items.length && inside ? 12 : 0 }}\n      transition={reduce ? instant : motionTokens.spring.smooth}\n    >\n      <AnimatePresence initial={false}>\n        {items.map((item, index) => (\n          <FileRow\n            key={item.id}\n            item={item}\n            reduce={reduce}\n            delay={reduce ? 0 : Math.min(Math.max(0, index - batchStart), 7) * motionTokens.stagger.item * 1.6}\n            fresh={freshIds.has(item.id)}\n            canRetry={!!onUpload}\n            onRemove={() => removeItem(item)}\n            onRetry={() => retryItem(item)}\n            removeRef={(node) => {\n              if (node) removeRefs.current.set(item.id, node);\n              else removeRefs.current.delete(item.id);\n            }}\n          />\n        ))}\n      </AnimatePresence>\n    </motion.ul>\n  );\n\n  // No grid gap: the error row and the file list carry their own spacing, so each can open from a true zero height.\n  return (\n    <div\n      className=\"relative grid min-w-0 text-ink\"\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      onFocus={() => setFocusWithin(true)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusWithin(false);\n      }}\n    >\n      {/* A folded prompt is a slimmer surface, so its corners step down to the panel radius. Color is the only press response: the target never scales.\n        A file over the target closes the dashes into a solid edge and the target breathes, slowly, while it waits. */}\n      <div\n        ref={zoneRef}\n        className={cn(\n          \"group/zone relative grid min-w-0 [transition:background-color_var(--duration-standard)_var(--ease-standard),border-radius_var(--duration-spring)_var(--ease-spring)] motion-reduce:transition-none pointer-fine:has-[[data-trigger]:hover]:bg-hover has-[[data-trigger]:active]:bg-hover\",\n          compact ? \"rounded-panel\" : \"rounded-surface\",\n          dragging\n            ? \"animate-file-dropzone-breathe bg-[color-mix(in_oklab,var(--accent)_2.5%,var(--surface))] motion-reduce:animate-none\"\n            : \"bg-surface\",\n        )}\n        onDragEnter={(event) => {\n          if (!carriesFiles(event)) return;\n          event.preventDefault();\n          if (!dragDepth.current) track(event);\n          dragDepth.current += 1;\n          setDragging(true);\n        }}\n        onDragOver={(event) => {\n          if (!carriesFiles(event)) return;\n          event.preventDefault();\n          event.dataTransfer.dropEffect = \"copy\";\n          track(event);\n        }}\n        onDragLeave={(event) => {\n          if (!carriesFiles(event)) return;\n          dragDepth.current = Math.max(0, dragDepth.current - 1);\n          if (!dragDepth.current) setDragging(false);\n        }}\n        onDrop={(event) => {\n          event.preventDefault();\n          dragDepth.current = 0;\n          setDragging(false);\n          if (event.dataTransfer.files.length) addFiles(event.dataTransfer.files);\n        }}\n      >\n        {/* Light that follows the pointer: a wash inside, and a brighter stretch of the edge nearest the file. */}\n        <motion.span\n          className={cn(light, dragging ? \"opacity-100\" : \"opacity-0\")}\n          style={{ backgroundImage: washLight }}\n          aria-hidden=\"true\"\n        />\n        {/* The edge is a drawn stroke, fitted to the perimeter in script, so a drag can close its dashes into one line. */}\n        <svg\n          className=\"pointer-events-none absolute top-[.5px] left-[.5px] h-[calc(100%-1px)] w-[calc(100%-1px)] overflow-visible\"\n          aria-hidden=\"true\"\n        >\n          <rect\n            ref={edgeRef}\n            className={cn(\n              \"fill-none stroke-1 [transition:stroke_var(--duration-fast)_var(--ease-standard),stroke-dasharray_var(--duration-standard)_var(--ease-standard),stroke-dashoffset_var(--duration-standard)_var(--ease-standard)] motion-reduce:transition-none pointer-fine:group-has-[[data-trigger]:hover]/zone:stroke-ink-3\",\n              dragging\n                ? \"stroke-[color:color-mix(in_oklab,var(--accent)_40%,var(--line))] [stroke-dasharray:7_0] [stroke-dashoffset:1.75]\"\n                : \"stroke-line-strong [stroke-dasharray:3.5_3.5] [stroke-dashoffset:0]\",\n            )}\n            width=\"100%\"\n            height=\"100%\"\n            rx={33.5}\n            pathLength={1358}\n          />\n        </svg>\n        <motion.span\n          className={cn(\n            light,\n            \"p-[1.5px] [mask:linear-gradient(#000_0_0)_content-box_exclude,linear-gradient(#000_0_0)]\",\n            dragging ? \"opacity-100\" : \"opacity-0\",\n          )}\n          style={{ backgroundImage: edgeLight }}\n          aria-hidden=\"true\"\n        />\n        {/* Children ignore pointer events, so the whole target is one hit area for clicks and drags. */}\n        <motion.button\n          ref={dropRef}\n          type=\"button\"\n          data-trigger=\"\"\n          className=\"flex w-full cursor-pointer flex-col items-stretch justify-center rounded-[inherit] border-0 bg-transparent px-5 py-[34px] text-center tracking-body *:pointer-events-none\"\n          onClick={() => inputRef.current?.click()}\n          aria-describedby={compact ? descriptionId : `${descriptionId} ${noteId}`}\n          initial={false}\n          animate={{\n            paddingTop: compact ? 16 : 26,\n            paddingBottom: compact ? (rowsInside ? 8 : 16) : rowsInside ? 26 : 34,\n          }}\n          transition={reduce ? instant : motionTokens.spring.smooth}\n        >\n          {/* Three sheets fan open on hover and spread wider under a file; a folded prompt opens them again to receive it. */}\n          <AnimatePresence initial={false}>\n            {(!compact || dragging) && (\n              <motion.span key=\"icon\" className=\"flex flex-col overflow-hidden\" {...slot}>\n                {/* The slot's top margin is room for the sheets to lift without clipping. */}\n                <span className=\"relative mt-2.5 mb-3.5 block h-11 w-9 self-center\" aria-hidden=\"true\">\n                  <span\n                    className={cn(\n                      sheet,\n                      dragging ? \"border-line-strong text-ink-2\" : \"border-line text-ink-3\",\n                      dragging ? \"[--r:-22deg] [--x:-20px] [--y:-2px]\" : \"[--r:-10deg] [--x:-9px] [--y:2px]\",\n                      \"pointer-fine:group-has-[[data-trigger]:hover]/zone:[--r:-15deg] pointer-fine:group-has-[[data-trigger]:hover]/zone:[--x:-13px]\",\n                    )}\n                    data-sheet=\"back\"\n                  />\n                  <span\n                    className={cn(\n                      sheet,\n                      dragging ? \"border-line-strong text-ink-2\" : \"border-line text-ink-3\",\n                      dragging ? \"[--r:22deg] [--x:20px] [--y:-2px]\" : \"[--r:10deg] [--x:9px] [--y:2px]\",\n                      \"pointer-fine:group-has-[[data-trigger]:hover]/zone:[--r:15deg] pointer-fine:group-has-[[data-trigger]:hover]/zone:[--x:13px]\",\n                    )}\n                    data-sheet=\"side\"\n                  />\n                  <span\n                    className={cn(\n                      sheet,\n                      dragging ? \"border-line-strong text-ink-2\" : \"border-line text-ink-3\",\n                      dragging ? \"[--y:-8px]\" : \"[--y:0px]\",\n                      \"[--r:0deg] [--x:0px] pointer-fine:group-has-[[data-trigger]:hover]/zone:[--y:-2px]\",\n                    )}\n                    data-sheet=\"front\"\n                  >\n                    <ArrowUp\n                      className={cn(\n                        \"transition-transform duration-spring ease-spring motion-reduce:transition-none\",\n                        dragging && \"-translate-y-[3px]\",\n                      )}\n                      size={14}\n                      strokeWidth={2}\n                    />\n                  </span>\n                </span>\n              </motion.span>\n            )}\n          </AnimatePresence>\n          {/* Full width and centered text: the label box never moves, so an outgoing label leaves from exactly where it was. */}\n          <strong className=\"relative block text-sm leading-body font-medium\">\n            <TextSwap\n              text={dragging ? dropCopy : label}\n              className=\"inline-block max-w-full truncate align-top\"\n              reduce={reduce}\n            />\n          </strong>\n          <span id={descriptionId} className=\"mt-1.5 text-sm leading-body text-ink-2\">\n            {description}\n          </span>\n          <AnimatePresence initial={false}>\n            {!compact && (\n              <motion.span key=\"note\" className=\"flex flex-col overflow-hidden\" {...slot}>\n                <small id={noteId} className=\"pt-1.5 text-xs leading-body text-ink-3\">\n                  {noteCopy}\n                </small>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </motion.button>\n        {inside && list}\n      </div>\n      <input\n        ref={inputRef}\n        className=\"sr-only\"\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        tabIndex={-1}\n        aria-hidden=\"true\"\n        onChange={(event) => {\n          if (event.target.files) addFiles(event.target.files);\n          event.target.value = \"\";\n        }}\n      />\n      <AnimatePresence initial={false}>{error ? <ErrorRow key=\"error\" text={error} /> : null}</AnimatePresence>\n      {!inside && list}\n      <output className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </output>\n    </div>\n  );\n}\n\nexport default FileDropzone;\n",
      "type": "registry:component",
      "target": "@components/iris/file-dropzone/file-dropzone.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/file-dropzone",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "FileDropzone",
    "tags": [
      "field",
      "files"
    ],
    "docs": "https://irisui.dev/components/file-dropzone"
  }
}
