{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/file-dropzone/file-dropzone.tsx",
      "type": "registry:component",
      "target": "@components/xzero/file-dropzone/file-dropzone.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { DragEvent, KeyboardEvent } from \"react\";\nimport { ArrowUp, CircleAlert, File as FileIcon, FileArchive, FileImage, FilePlay, FileText, RotateCw, X } from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionTemplate, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport type { HTMLMotionProps, TargetAndTransition, Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./file-dropzone.module.css\";\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 = { id: string; name: string; size: number; status?: FileDropzoneStatus; progress?: number; error?: string; retryable?: boolean; file?: File; preview?: string };\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 = (item: FileDropzoneItem, options: { onProgress: (percent: number) => void; signal: AbortSignal }) => Promise<void>;\n\nexport type FileDropzoneProps = {\n  accept?: string; multiple?: boolean; maxFiles?: number; onFilesChange?: (files: File[]) => void; label?: string; 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: .3 };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };\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: .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: .42, bounce: 0 };\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 <AnimatePresence mode=\"popLayout\" initial={false}><Swap key={text} className={className} initial={reduce ? { opacity: 0 } : textIn} animate={shown} exit={reduce ? { opacity: 0, transition: fade } : textOut} transition={reduce ? fade : enter}>{text}</Swap></AnimatePresence>;\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 <><span className={styles.srOnly}>{text}</span><span className={styles.words} aria-hidden=\"true\"><AnimatePresence initial={false} mode=\"popLayout\">{words.map((word, index) => <motion.span key={`${index}:${word}`} className={styles.word}\n    initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n    exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.35em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }}\n    transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{index < words.length - 1 ? `${word} ` : word}</motion.span>)}</AnimatePresence></span></>;\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]) => { if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight); });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.div className={styles.messageSlot} initial={{ height: 0, opacity: 0 }} animate={{ height, opacity: 1 }} exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>\n    <motion.div ref={copyRef} className={styles.error} role=\"alert\" initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ y: 0, filter: \"blur(0px)\" }} transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}><CircleAlert className={styles.errorIcon} size={14} strokeWidth={2.25} aria-hidden=\"true\" /><span className={styles.errorCopy}><MotionText text={text} /></span></motion.div>\n  </motion.div>;\n}\n\n/** The circle closes, then the tick draws through it. */\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return <svg className={styles.stateIcon} width={14} height={14} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.25} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <motion.path d=\"M12 2.5a9.5 9.5 0 1 1 0 19a9.5 9.5 0 1 1 0-19\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: motionTokens.duration.considered * .7, ease: [...motionTokens.ease.enter] }} />\n    <motion.path d=\"m8.2 12.4 2.6 2.6 5-5.2\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ ...enter, delay: .16 }} />\n  </svg>;\n}\n\ntype RowProps = { item: FileDropzoneItem; reduce: boolean; delay: number; fresh: boolean; canRetry: boolean; onRemove: () => void; onRetry: () => void; removeRef: (node: HTMLButtonElement | null) => void };\n\nfunction Thumb({ item }: { item: FileDropzoneItem }) {\n  const [broken, setBroken] = useState(false);\n  if (!item.preview || broken) return <span className={styles.fileIcon}><TypeIcon name={item.name} /></span>;\n  // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic, and previews are often object URLs\n  return <span className={styles.thumb}><img src={item.preview} alt=\"\" width={40} height={40} decoding=\"async\" onError={() => setBroken(true)} /></span>;\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 + .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) { setSeen(status); if (status === \"uploading\") setFilled(false); }\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)) { progress.jump(target); return; }\n    const controls = animate(progress, target, { ...motionTokens.spring.smooth, onComplete: status === \"uploaded\" ? () => setFilled(true) : undefined });\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 => { if (status === \"uploaded\" && value >= 99.5) setFilled(true); });\n  const failed = status === \"failed\";\n  const retry = failed && canRetry && item.retryable !== false;\n  return <motion.li className={styles.item} initial={reduce ? { opacity: 0 } : { height: 0 }} animate={{ height: \"auto\", opacity: 1 }}\n    exit={reduce ? { opacity: 0, transition: fade } : { height: 0, opacity: 0, transition: { height: collapse, opacity: { ...exitFast, delay: .04 } } }}\n    transition={reduce ? fade : { height: { ...motionTokens.spring.smooth, delay }, opacity: fade }}>\n    {/* New rows rise a little and unblur while their height opens, so the rows below make room instead of jumping. */}\n    <motion.div ref={rowRef} className={`${styles.row} ${failed ? styles.isFailed : \"\"}`} initial={reduce ? false : { opacity: 0, y: -22, scale: .94, filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n      exit={reduce ? undefined : { scale: .97, filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }}\n      transition={reduce ? instant : { y: { ...land, delay }, scale: { ...land, delay }, opacity: { ...enter, delay }, filter: { ...enter, delay } }}>\n      <Thumb item={item} />\n      <span className={styles.copy}>\n        <span className={styles.name} title={item.name}>{item.name}</span>\n        <span className={styles.meta}>\n          <span>{formatFileSize(item.size)}</span>\n          {phase && <><span className={styles.dot} aria-hidden=\"true\">·</span><span className={styles.phase}><AnimatePresence mode=\"popLayout\" initial={false}>\n            <Swap key={phase} className={`${styles.state} ${phase === \"uploaded\" ? styles.uploaded : phase === \"failed\" ? styles.failed : \"\"}`} initial={reduce ? { opacity: 0 } : textIn} animate={shown} exit={reduce ? { opacity: 0, transition: fade } : textOut} transition={reduce ? fade : enter}>\n              {phase === \"uploading\" ? <span>Uploading <motion.span className={styles.percent}>{percent}</motion.span></span>\n                : phase === \"uploaded\" ? <><DrawnCheck reduce={reduce} /><span>Uploaded</span></>\n                : <><CircleAlert className={styles.stateIcon} size={14} strokeWidth={2.25} aria-hidden=\"true\" /><span className={styles.reason} title={item.error}><span className={styles.srOnly}>Failed: </span>{item.error || \"Upload failed\"}</span></>}\n            </Swap>\n          </AnimatePresence></span></>}\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}>{phase === \"uploading\" && <motion.span key=\"bar\" className={styles.barFrame} initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }}\n          exit={reduce ? { opacity: 0, transition: fade } : { height: 0, opacity: 0, transition: { height: { ...collapse, delay: .22 }, opacity: { ...exitFast, delay: .16 } } }}\n          transition={reduce ? fade : { height: motionTokens.spring.smooth, opacity: enter }}>\n          <span className={styles.bar} role=\"progressbar\" aria-label={`Uploading ${item.name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(clamp(target))}><motion.span className={styles.barFill} style={{ x }} /></span>\n        </motion.span>}</AnimatePresence>\n      </span>\n      <span className={styles.actions}>\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}>{retry && <motion.span key=\"retry\" className={styles.retrySlot} initial={reduce ? { opacity: 0 } : { width: 0, opacity: 0 }} animate={{ width: \"auto\", opacity: 1 }}\n          exit={reduce ? { opacity: 0, transition: fade } : { width: 0, opacity: 0, transition: { width: collapse, opacity: { duration: motionTokens.duration.instant } } }}\n          transition={reduce ? fade : { width: motionTokens.spring.smooth, opacity: enter }}>\n          <motion.span className={styles.retryInner} initial={reduce ? false : { scale: .8, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ scale: 1, filter: \"blur(0px)\" }} transition={reduce ? instant : motionTokens.spring.snappy}>\n            <button type=\"button\" className={styles.retry} onClick={onRetry} aria-label={`Retry ${item.name}`} title=\"Retry\"><RotateCw size={14} strokeWidth={2} aria-hidden=\"true\" /><span className={styles.retryLabel}>Retry</span></button>\n          </motion.span>\n        </motion.span>}</AnimatePresence>\n        <button ref={removeRef} type=\"button\" className={styles.remove} onClick={onRemove} aria-label={`Remove ${item.name}`} title=\"Remove\"><X size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n      </span>\n    </motion.div>\n  </motion.li>;\n}\n\nexport function FileDropzone({ accept, multiple = true, maxFiles = 5, onFilesChange, label = \"Add files\", description = \"Drop files here or choose from your device\", defaultItems, onUpload, maxSize, listPlacement = \"below\", note, dropLabel, compactAt }: 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 () => { active.forEach(controller => controller.abort()); previews.forEach(url => URL.revokeObjectURL(url)); };\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), pointerY = useMotionValue(50);\n  const glowSpring = { stiffness: 260, damping: 32, mass: .8 };\n  const glowX = useSpring(pointerX, glowSpring), 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, y = ((event.clientY - box.top) / Math.max(1, box.height)) * 100;\n    if (reduce || !dragging) { pointerX.jump(x); pointerY.jump(y); glowX.jump(x); glowY.jump(y); }\n    else { pointerX.set(x); pointerY.set(y); }\n  };\n\n  const shakeZone = () => { if (!reduce && zoneRef.current) animate(zoneRef.current, SHAKE, shakeTransition); };\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, edge = edgeRef.current;\n    if (!zone || !edge) return;\n    const fit = () => {\n      const width = zone.offsetWidth - 1, height = zone.offsetHeight - 1;\n      const radius = Math.max(0, Math.min(parseFloat(getComputedStyle(zone).borderTopLeftRadius) - .5, width / 2, height / 2));\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 = () => { fit(); frame = requestAnimationFrame(follow); };\n    const onRun = (event: TransitionEvent) => { if (event.target === zone && event.propertyName.includes(\"radius\")) { cancelAnimationFrame(frame); follow(); } };\n    const onEnd = (event: TransitionEvent) => { if (event.target === zone && event.propertyName.includes(\"radius\")) { cancelAnimationFrame(frame); fit(); } };\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 () => { observer?.disconnect(); cancelAnimationFrame(frame); zone.removeEventListener(\"transitionrun\", onRun); zone.removeEventListener(\"transitionend\", onEnd); zone.removeEventListener(\"transitioncancel\", onEnd); };\n  }, []);\n\n  const filesOf = (list: FileDropzoneItem[]) => list.flatMap(item => item.file ? [item.file] : []);\n  const patch = (id: string, next: (item: FileDropzoneItem) => FileDropzoneItem) => 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({ ...item, status: \"uploading\", progress: 0, error: undefined }, {\n      signal: controller.signal,\n      onProgress: percent => { if (!controller.signal.aborted) patch(item.id, current => current.status === \"uploading\" ? { ...current, progress: Math.max(current.progress ?? 0, clamp(percent)) } : current); },\n    }).then(() => {\n      if (controller.signal.aborted) return;\n      patch(item.id, current => ({ ...current, status: \"uploaded\", progress: 100 }));\n      setAnnouncement(`${item.name} uploaded`);\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    }).finally(() => { if (controllers.current.get(item.id) === controller) controllers.current.delete(item.id); });\n  }\n\n  function addFiles(incoming: FileList | File[]) {\n    const list = Array.from(incoming);\n    const accepted = accept?.split(\",\").map(value => value.trim().toLowerCase()).filter(Boolean) ?? [];\n    const matching = list.filter(file => accepted.length === 0 || accepted.some(type => type.startsWith(\".\") ? file.name.toLowerCase().endsWith(type) : type.endsWith(\"/*\") ? file.type.startsWith(type.slice(0, -1)) : file.type === type));\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((file, index) => matching.findIndex(other => same(other, file)) === index && !kept.some(item => item.file && same(item.file, file)));\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 = file.type.startsWith(\"image/\") && typeof URL.createObjectURL === \"function\" ? URL.createObjectURL(file) : undefined;\n      if (preview) ownedPreviews.current.set(id, preview);\n      return { id, name: file.name, size: file.size, file, preview, status: tooLarge ? \"failed\" : onUpload ? \"uploading\" : undefined, progress: 0, error: tooLarge ? `File is larger than ${formatFileSize(maxSize)}` : undefined, retryable: !tooLarge };\n    });\n    const rejected = matching.length !== list.length;\n    const overflow = fresh.length > added.length;\n    setError(rejected ? (list.length - matching.length === 1 && list.length === 1 ? `${list[0]!.name} is not an accepted file type.` : \"Some files were not added because their type is not accepted.\") : overflow ? `You can add up to ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}.` : \"\");\n    if (rejected || overflow) shakeZone();\n    if (!added.length) return;\n    if (!multiple) { controllers.current.forEach(controller => controller.abort()); controllers.current.clear(); }\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(`${added.length} ${added.length === 1 ? \"file\" : \"files\"} added.${failed.length ? ` ${failed.map(item => `${item.name}: ${item.error}`).join(\". \")}.` : \"\"}`);\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) { ownedPreviews.current.delete(target.id); window.setTimeout(() => URL.revokeObjectURL(preview), 600); }\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>(`.${styles.row}`);\n      const remove = row?.querySelector<HTMLButtonElement>(`.${styles.remove}`);\n      if (remove) { event.preventDefault(); remove.click(); }\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>(`.${styles.row}`));\n    const row = button?.closest<HTMLElement>(`.${styles.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    (nextRow.querySelector<HTMLButtonElement>(`.${button.classList.contains(styles.retry!) ? styles.retry : styles.remove}`) ?? nextRow.querySelector<HTMLButtonElement>(`.${styles.remove}`))?.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(() => { addRef.current = addFiles; });\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 = { initial: reduce ? { opacity: 0 } : { height: 0, opacity: 0 }, animate: { height: \"auto\", opacity: 1 }, exit: reduce ? { opacity: 0, transition: fade } : { height: 0, opacity: 0, transition: { height: collapse, opacity: exitFast } }, transition: reduce ? fade : { height: motionTokens.spring.smooth, opacity: enter } };\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 = <motion.ul className={styles.list} aria-label=\"Files\" aria-hidden={items.length ? undefined : true} onKeyDown={moveFocus} initial={false}\n    animate={{ paddingTop: items.length && !inside ? 4 : 0, paddingBottom: items.length && inside ? 12 : 0 }} transition={reduce ? instant : motionTokens.spring.smooth}>\n    <AnimatePresence initial={false}>{items.map((item, index) => <FileRow key={item.id} item={item} reduce={reduce} delay={reduce ? 0 : Math.min(Math.max(0, index - batchStart), 7) * motionTokens.stagger.item * 1.6} fresh={freshIds.has(item.id)} canRetry={!!onUpload}\n      onRemove={() => removeItem(item)} onRetry={() => retryItem(item)} removeRef={node => { if (node) removeRefs.current.set(item.id, node); else removeRefs.current.delete(item.id); }} />)}</AnimatePresence>\n  </motion.ul>;\n\n  return <div className={styles.wrapper} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)}\n    onFocus={() => setFocusWithin(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusWithin(false); }}>\n    <div ref={zoneRef} className={`${styles.dropzone} ${dragging ? styles.dragging : \"\"} ${inside ? styles.inside : \"\"} ${compact ? styles.compact : \"\"}`}\n      onDragEnter={event => { if (!carriesFiles(event)) return; event.preventDefault(); if (!dragDepth.current) track(event); dragDepth.current += 1; setDragging(true); }}\n      onDragOver={event => { if (!carriesFiles(event)) return; event.preventDefault(); event.dataTransfer.dropEffect = \"copy\"; track(event); }}\n      onDragLeave={event => { if (!carriesFiles(event)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (!dragDepth.current) setDragging(false); }}\n      onDrop={event => { event.preventDefault(); dragDepth.current = 0; setDragging(false); if (event.dataTransfer.files.length) addFiles(event.dataTransfer.files); }}>\n      <motion.span className={styles.wash} style={{ backgroundImage: washLight }} aria-hidden=\"true\" />\n      <svg className={styles.edge} aria-hidden=\"true\"><rect ref={edgeRef} width=\"100%\" height=\"100%\" rx={33.5} pathLength={1358} /></svg>\n      <motion.span className={styles.glow} style={{ backgroundImage: edgeLight }} aria-hidden=\"true\" />\n      {/* Children ignore pointer events, so the whole target is one hit area for clicks and drags. */}\n      <motion.button ref={dropRef} type=\"button\" className={styles.trigger} onClick={() => inputRef.current?.click()} aria-describedby={compact ? descriptionId : `${descriptionId} ${noteId}`}\n        initial={false} animate={{ paddingTop: compact ? 16 : 26, paddingBottom: compact ? (rowsInside ? 8 : 16) : rowsInside ? 26 : 34 }} transition={reduce ? instant : motionTokens.spring.smooth}>\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}>{(!compact || dragging) && <motion.span key=\"icon\" className={styles.iconSlot} {...slot}>\n          <span className={styles.sheets} aria-hidden=\"true\">\n            <span className={styles.sheet} data-sheet=\"back\" />\n            <span className={styles.sheet} data-sheet=\"side\" />\n            <span className={styles.sheet} data-sheet=\"front\"><ArrowUp size={14} strokeWidth={2} /></span>\n          </span>\n        </motion.span>}</AnimatePresence>\n        <strong className={styles.label}><TextSwap text={dragging ? dropCopy : label} className={styles.labelText} reduce={reduce} /></strong>\n        <span id={descriptionId} className={styles.hint}>{description}</span>\n        <AnimatePresence initial={false}>{!compact && <motion.span key=\"note\" className={styles.noteSlot} {...slot}><small id={noteId} className={styles.note}>{noteCopy}</small></motion.span>}</AnimatePresence>\n      </motion.button>\n      {inside && list}\n    </div>\n    <input ref={inputRef} className={styles.input} type=\"file\" accept={accept} multiple={multiple} tabIndex={-1} aria-hidden=\"true\" onChange={event => { if (event.target.files) addFiles(event.target.files); event.target.value = \"\"; }} />\n    <AnimatePresence initial={false}>{error ? <ErrorRow key=\"error\" text={error} /> : null}</AnimatePresence>\n    {!inside && list}\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n  </div>;\n}\n\nexport default FileDropzone;\n"
    },
    {
      "path": "registry/components/file-dropzone/file-dropzone.module.css",
      "type": "registry:component",
      "target": "@components/xzero/file-dropzone/file-dropzone.module.css",
      "content": "/* No grid gap: the error row and the file list carry their own spacing, so each can open from a true zero height. */\n.wrapper { position: relative; display: grid; min-width: 0; color: var(--foreground); }\n.dropzone { position: relative; display: grid; min-width: 0; border-radius: var(--radius-surface); background: var(--surface); transition: background-color var(--duration-standard) var(--ease-standard), border-radius var(--duration-spring) var(--ease-spring); }\n/* A folded prompt is a slimmer surface, so its corners step down to the panel radius. */\n.dropzone.compact { border-radius: var(--radius-panel); }\n/* The edge is a drawn stroke, fitted to the perimeter in script, so a drag can close its dashes into one line. */\n.edge { position: absolute; top: .5px; left: .5px; width: calc(100% - 1px); height: calc(100% - 1px); overflow: visible; pointer-events: none; }\n.edge rect { fill: none; stroke: var(--border-strong); stroke-width: 1; stroke-dasharray: 3.5 3.5; stroke-dashoffset: 0; transition: stroke var(--duration-fast) var(--ease-standard), stroke-dasharray var(--duration-standard) var(--ease-standard), stroke-dashoffset var(--duration-standard) var(--ease-standard); }\n.trigger { display: flex; width: 100%; flex-direction: column; align-items: stretch; justify-content: center; border: 0; border-radius: inherit; padding: 34px var(--space-5); background: transparent; color: inherit; font: inherit; letter-spacing: var(--tracking-body); text-align: center; cursor: pointer; }\n.trigger > * { pointer-events: none; }\n@media (hover: hover) and (pointer: fine) {\n  .dropzone:has(.trigger:hover) { background: var(--surface-muted); }\n  .dropzone:has(.trigger:hover) .edge rect { stroke: var(--text-muted); }\n}\n/* Color is the only press response: the target never scales. */\n.dropzone:has(.trigger:active) { background: var(--surface-muted); }\n/* A file over the target closes the dashes into a solid edge and the target breathes, slowly, while it waits. */\n.dropzone.dragging { background: color-mix(in oklab, var(--accent) 2.5%, var(--surface)); animation: breathe 2.2s var(--ease-in-out) infinite; }\n@keyframes breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.012); } }\n/* Light that follows the pointer: a wash inside, and a brighter stretch of the edge nearest the file. */\n.wash, .glow { position: absolute; inset: 0; border-radius: inherit; opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-standard); }\n.glow { padding: 1.5px; -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); }\n.dragging .wash, .dragging .glow { opacity: 1; }\n.dragging .edge rect { stroke: color-mix(in oklab, var(--accent) 40%, var(--border)); stroke-dasharray: 7 0; stroke-dashoffset: 1.75; }\n/* No gaps between the prompt's parts: each slot carries its own space, so a folding slot takes its space with it. */\n.iconSlot, .noteSlot { display: flex; flex-direction: column; overflow: hidden; }\n/* Three sheets of paper, the target's one illustration. The slot's top margin is room for them to lift without clipping. */\n.sheets { position: relative; display: block; width: 36px; height: 44px; align-self: center; margin: 10px 0 14px; }\n.sheet {\n  --r: 0deg; --x: 0px; --y: 0px;\n  position: absolute; inset: 0; display: grid; place-items: end center; padding: 0 0 5px;\n  border: 1px solid var(--border); border-radius: 7px; background: var(--surface-raised); color: var(--text-muted);\n  box-shadow: 0 1px 2px oklch(0% 0 0 / .05), 0 3px 8px oklch(0% 0 0 / .04);\n  transform: translate(var(--x), var(--y)) rotate(var(--r));\n  transform-origin: 50% 100%;\n  transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard);\n}\n.sheet::before, .sheet::after { position: absolute; top: 8px; left: 7px; width: 16px; height: 2px; border-radius: 2px; background: currentColor; content: \"\"; opacity: .3; }\n.sheet::after { top: 13px; width: 11px; }\n.sheet > svg { transition: transform var(--duration-spring) var(--ease-spring); }\n.dragging .sheet > svg { transform: translateY(-3px); }\n.sheet[data-sheet=\"back\"] { --r: -10deg; --x: -9px; --y: 2px; }\n.sheet[data-sheet=\"side\"] { --r: 10deg; --x: 9px; --y: 2px; }\n@media (hover: hover) and (pointer: fine) {\n  .dropzone:has(.trigger:hover) .sheet[data-sheet=\"back\"] { --r: -15deg; --x: -13px; }\n  .dropzone:has(.trigger:hover) .sheet[data-sheet=\"side\"] { --r: 15deg; --x: 13px; }\n  .dropzone:has(.trigger:hover) .sheet[data-sheet=\"front\"] { --y: -2px; }\n}\n.dragging .sheet { color: var(--text-secondary); border-color: var(--border-strong); }\n.dropzone.dragging .sheet[data-sheet=\"back\"] { --r: -22deg; --x: -20px; --y: -2px; }\n.dropzone.dragging .sheet[data-sheet=\"side\"] { --r: 22deg; --x: 20px; --y: -2px; }\n.dropzone.dragging .sheet[data-sheet=\"front\"] { --y: -8px; }\n/* Full width and centered text: the label box never moves, so an outgoing label leaves from exactly where it was. */\n.label { position: relative; display: block; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n.labelText { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: top; white-space: nowrap; }\n.hint { margin-top: 6px; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n.note { padding-top: 6px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n.input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n.messageSlot { overflow: hidden; }\n.error { display: flex; align-items: flex-start; gap: 6px; padding-top: var(--space-3); color: var(--danger); font-size: var(--text-sm); line-height: var(--leading-body); }\n/* The icon sits on the first line's optical center, so a wrapped message keeps it at the top. */\n.errorIcon { flex: 0 0 auto; margin-top: 3px; }\n.errorCopy { position: relative; min-width: 0; flex: 1; }\n\n.list { display: grid; min-width: 0; margin: 0; padding: 0; list-style: none; container: dropzone-list / inline-size; }\n.inside .list { padding-inline: 12px; }\n/* Rows animate height; the gap lives inside the clipped row as margin, so a closing row reaches a true zero. */\n.item { min-width: 0; overflow: hidden; }\n.row { position: relative; display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; column-gap: 12px; min-height: 58px; margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius-control); padding: 8px 8px 8px 9px; background: var(--surface); transition: border-color var(--duration-standard) var(--ease-standard); }\n.row.isFailed { border-color: color-mix(in oklab, var(--danger) 26%, var(--border)); }\n.fileIcon { display: grid; width: 40px; height: 40px; place-items: center; color: var(--text-secondary); }\n.thumb { display: block; width: 40px; height: 40px; overflow: hidden; border-radius: 10px; background: var(--surface-muted); box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .06); }\n.thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }\n.copy { display: grid; min-width: 0; }\n.name { overflow: hidden; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }\n.meta { display: flex; min-width: 0; align-items: center; margin-top: 1px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; white-space: nowrap; }\n.dot { padding-inline: 6px; }\n/* The outgoing status is measured against this box when it pops out to leave. */\n.phase { position: relative; display: flex; min-width: 0; flex: 1; }\n.state { display: inline-flex; min-width: 0; align-items: center; gap: 5px; }\n.uploaded { color: var(--text-secondary); }\n.uploaded .stateIcon { color: var(--success); }\n.failed { color: var(--danger); }\n.stateIcon { flex: 0 0 auto; }\n.reason { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n.percent { font-variant-numeric: tabular-nums; }\n.barFrame { display: block; overflow: hidden; }\n.bar { display: block; height: 4px; margin: 7px 0 2px; overflow: hidden; border-radius: var(--radius-pill); background: var(--border); }\n/* The fill slides in from the left instead of scaling, so its rounded end keeps its shape at every value. */\n.barFill { display: block; width: 100%; height: 100%; border-radius: inherit; background: var(--accent); }\n.actions { display: flex; align-items: center; }\n.retrySlot { display: flex; overflow: hidden; }\n.retryInner { display: flex; padding: 3px 6px 3px 3px; }\n.retry, .remove { display: grid; place-items: center; border: 0; color: var(--text-secondary); font: inherit; 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), transform var(--duration-spring) var(--ease-spring); }\n.retry { display: flex; height: 30px; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 0 12px 0 10px; background: var(--surface); color: var(--foreground); font-size: var(--text-xs); font-weight: 500; letter-spacing: var(--tracking-body); white-space: nowrap; }\n/* On narrow lists Retry keeps only its icon, so the failure reason keeps the room to be read in full. */\n@container dropzone-list (max-width: 440px) { .retry { width: 30px; justify-content: center; padding: 0; } .retryLabel { display: none; } }\n/* On the narrowest lists the type icon steps aside for the name and status. */\n@container dropzone-list (max-width: 300px) { .row { grid-template-columns: minmax(0, 1fr) auto; padding-left: 14px; } .fileIcon, .thumb { display: none; } }\n.remove { width: 32px; height: 32px; border-radius: var(--radius-pill); background: transparent; }\n@media (hover: hover) and (pointer: fine) {\n  .retry:hover { border-color: var(--border-strong); background: var(--surface-muted); }\n  .remove:hover { background: var(--surface-muted); color: var(--foreground); }\n}\n/* Quick press, spring release. Neither button anchors a floating layer. */\n.retry:active, .remove:active { transform: scale(.97); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }\n/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */\n.words { position: relative; display: block; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) {\n  .dropzone, .edge rect, .sheet, .sheet > svg, .row, .retry, .remove, .wash, .glow { transition: none; }\n  .dropzone.dragging { animation: none; }\n  .retry:active, .remove:active { transform: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/file-dropzone",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "files"
    ],
    "docs": "https://ui.xzero.co.uk/components/file-dropzone",
    "markdown": "https://ui.xzero.co.uk/components/file-dropzone/markdown"
  }
}