{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "File Upload",
  "description": "A dropzone that wakes when files cross the page and lifts as they reach it. Rows rise in with per-file progress on a spring, and refused files are explained inline.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-auto-height.json",
    "https://eased.dev/r/eased-text-swap.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/file-upload/file-upload.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ClipboardEvent as ReactClipboardEvent,\n  type DragEvent as ReactDragEvent,\n  type ReactNode,\n} from \"react\";\nimport { AutoHeight } from \"../_primitives/auto-height/auto-height\";\nimport { TextSwap } from \"../_primitives/text-swap/text-swap\";\nimport { blur, ease, enter, exitFast, instant, reducedFade, smooth, snappy, useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\n\n/* ---------- The model ------------------------------------------------------------ */\n\nexport type FileUploadStatus = \"uploading\" | \"done\" | \"error\";\n\n/** One file in the list. Plain data: keep it in your own state if you control the list. */\nexport type FileUploadItem = {\n  id: string;\n  name: string;\n  /** Bytes. */\n  size: number;\n  /** MIME type, e.g. `application/pdf`. */\n  type: string;\n  status: FileUploadStatus;\n  /** 0–1 while uploading; leave it out when unknown. */\n  progress?: number;\n  /** Shown in place of the size when `status` is `error`, e.g. \"Over 10 MB\". */\n  error?: string;\n  /** False for errors a retry cannot fix (too large). Default true. */\n  retryable?: boolean;\n  /** The original file, when it came from this device. */\n  file?: File;\n};\n\n/**\n * Uploads one file. Report progress 0–1 through `onProgress`, resolve when stored, reject\n * with an Error whose message the row shows. Abort on `signal` (the row was removed).\n */\nexport type FileUploadFn = (file: File, ctx: { onProgress: (fraction: number) => void; signal: AbortSignal; item: FileUploadItem }) => Promise<unknown>;\n\nexport type FileUploadRejection = { file: File; reason: \"type\" | \"count\"; message: string };\n\nexport type UseFileUploadOptions = {\n  /** Called for each accepted file. Without it, files are listed as done at once. */\n  upload?: FileUploadFn;\n  /** Same syntax as `<input accept>`: `\"image/*,.pdf\"`. Other files are refused with a message. */\n  accept?: string;\n  /** Bytes. Larger files are listed with an error so the person sees why. */\n  maxSize?: number;\n  /** Files past this count are refused with a message. */\n  maxFiles?: number;\n  /** Controlled list. Pair with `onValueChange`. */\n  value?: FileUploadItem[];\n  defaultValue?: FileUploadItem[];\n  onValueChange?: (items: FileUploadItem[]) => void;\n  /** Files that were refused and why. The component also says so under the dropzone. */\n  onReject?: (rejected: FileUploadRejection[]) => void;\n};\n\nexport type FileUploadController = ReturnType<typeof useFileUpload>;\n\nlet counter = 0;\nconst newId = () => (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto ? crypto.randomUUID() : `file-${Date.now()}-${++counter}`);\n\n/** `2.4 MB`, `840 kB`, with a no-break space (R47). */\nexport function formatBytes(bytes: number, locale?: Intl.LocalesArgument) {\n  if (bytes < 1000) return `${new Intl.NumberFormat(locale).format(bytes)} ${bytes === 1 ? \"byte\" : \"bytes\"}`;\n  const units = [\"kilobyte\", \"megabyte\", \"gigabyte\"] as const;\n  let i = -1;\n  let n = bytes;\n  while (n >= 1000 && i < units.length - 1) {\n    n /= 1000;\n    i++;\n  }\n  return new Intl.NumberFormat(locale, { style: \"unit\", unit: units[i], unitDisplay: \"short\", maximumFractionDigits: n < 10 ? 1 : 0 })\n    .format(n)\n    .replace(\" \", \" \");\n}\n\n/** Whether a file matches an `accept` string. */\nexport function matchesAccept(file: Pick<File, \"name\" | \"type\">, accept?: string) {\n  if (!accept) return true;\n  const name = file.name.toLowerCase();\n  const type = file.type.toLowerCase();\n  return accept.split(\",\").some((raw) => {\n    const rule = raw.trim().toLowerCase();\n    if (!rule) return false;\n    if (rule.startsWith(\".\")) return name.endsWith(rule);\n    if (rule.endsWith(\"/*\")) return type.startsWith(rule.slice(0, -1));\n    return type === rule;\n  });\n}\n\n/** `\"application/pdf,image/png,.jpg\"` → `\"PDF, PNG or JPG\"`. */\nexport function describeAccept(accept: string) {\n  const labels = accept\n    .split(\",\")\n    .map((r) => r.trim())\n    .filter(Boolean)\n    .map((r) => (r.startsWith(\".\") ? r.slice(1) : r.endsWith(\"/*\") ? `${r.slice(0, -2)}s` : (r.split(\"/\").pop() ?? r)).toUpperCase())\n    .map((l) => (l === \"IMAGES\" || l === \"VIDEOS\" || l === \"AUDIOS\" ? l.toLowerCase() : l));\n  const unique = [...new Set(labels)];\n  return unique.length > 1 ? `${unique.slice(0, -1).join(\", \")} or ${unique[unique.length - 1]}` : (unique[0] ?? \"\");\n}\n\nconst typeLabel = (item: Pick<FileUploadItem, \"name\" | \"type\">) => {\n  const ext = item.name.includes(\".\") ? item.name.split(\".\").pop() : undefined;\n  return (ext || item.type.split(\"/\").pop() || \"file\").slice(0, 4).toUpperCase();\n};\n\n/**\n * The upload state machine: add (pick, drop, paste) with accept, size and count checks,\n * upload with progress, abort on remove, retry on error. Use it directly to add files from\n * your own code (`add`), and pass it to `<FileUpload controller>`.\n */\nexport function useFileUpload({ upload, accept, maxSize, maxFiles, value, defaultValue, onValueChange, onReject }: UseFileUploadOptions = {}) {\n  const [inner, setInner] = useState<FileUploadItem[]>(defaultValue ?? []);\n  const items = value ?? inner;\n  const list = useRef(items);\n  const controllers = useRef(new Map<string, AbortController>());\n  const [rejection, setRejection] = useState<{ message: string; key: number } | null>(null);\n  const latest = useRef({ upload, onValueChange, onReject, controlled: value !== undefined, accept, maxSize, maxFiles });\n  useLayoutEffect(() => {\n    list.current = items;\n    latest.current = { upload, onValueChange, onReject, controlled: value !== undefined, accept, maxSize, maxFiles };\n  });\n\n  const commit = useCallback((next: FileUploadItem[]) => {\n    list.current = next;\n    if (!latest.current.controlled) setInner(next);\n    latest.current.onValueChange?.(next);\n  }, []);\n\n  const patch = useCallback(\n    (id: string, changes: Partial<FileUploadItem>) => {\n      if (!list.current.some((f) => f.id === id)) return;\n      commit(list.current.map((f) => (f.id === id ? { ...f, ...changes } : f)));\n    },\n    [commit],\n  );\n\n  const start = useCallback(\n    (item: FileUploadItem) => {\n      const fn = latest.current.upload;\n      if (!fn || !item.file) return;\n      const controller = new AbortController();\n      controllers.current.set(item.id, controller);\n      let last = 0;\n      fn(item.file, {\n        signal: controller.signal,\n        item,\n        onProgress: (fraction) => {\n          if (controller.signal.aborted || (fraction - last < 0.01 && fraction < 1)) return;\n          last = fraction;\n          patch(item.id, { progress: Math.min(1, Math.max(0, fraction)) });\n        },\n      }).then(\n        () => {\n          controllers.current.delete(item.id);\n          if (!controller.signal.aborted) patch(item.id, { status: \"done\", progress: 1, error: undefined });\n        },\n        (err: unknown) => {\n          controllers.current.delete(item.id);\n          if (!controller.signal.aborted) patch(item.id, { status: \"error\", error: err instanceof Error ? err.message : \"Upload failed\" });\n        },\n      );\n    },\n    [patch],\n  );\n\n  const add = useCallback(\n    (files: Iterable<File> | ArrayLike<File>) => {\n      const { accept, maxSize, maxFiles, upload, onReject } = latest.current;\n      const rejected: FileUploadRejection[] = [];\n      const room = maxFiles === undefined ? Infinity : Math.max(0, maxFiles - list.current.length);\n      const added: FileUploadItem[] = [];\n      for (const file of Array.from(files)) {\n        if (!matchesAccept(file, accept)) {\n          rejected.push({ file, reason: \"type\", message: `${file.name} isn’t supported.${accept ? ` Use ${describeAccept(accept)}.` : \"\"}` });\n        } else if (added.length >= room) {\n          rejected.push({ file, reason: \"count\", message: `Up to ${maxFiles} files. ${file.name} wasn’t added.` });\n        } else {\n          const tooBig = maxSize !== undefined && file.size > maxSize;\n          added.push({\n            id: newId(),\n            name: file.name || \"Pasted file\",\n            size: file.size,\n            type: file.type || \"application/octet-stream\",\n            status: tooBig ? \"error\" : upload ? \"uploading\" : \"done\",\n            progress: tooBig || !upload ? undefined : 0,\n            error: tooBig ? `Over ${formatBytes(maxSize!)}` : undefined,\n            retryable: tooBig ? false : undefined,\n            file,\n          });\n        }\n      }\n      if (rejected.length) {\n        const message =\n          rejected.length === 1 ? rejected[0].message : `${rejected.length} files weren’t added. ${accept ? `Use ${describeAccept(accept)}.` : \"\"}`.trim();\n        setRejection((r) => ({ message, key: (r?.key ?? 0) + 1 }));\n        onReject?.(rejected);\n      } else if (added.length) setRejection(null);\n      if (!added.length) return [];\n      commit([...list.current, ...added]);\n      for (const item of added) if (item.status === \"uploading\") start(item);\n      return added;\n    },\n    [commit, start],\n  );\n\n  const remove = useCallback(\n    (id: string) => {\n      controllers.current.get(id)?.abort();\n      controllers.current.delete(id);\n      commit(list.current.filter((f) => f.id !== id));\n    },\n    [commit],\n  );\n\n  const retry = useCallback(\n    (id: string) => {\n      const item = list.current.find((f) => f.id === id);\n      if (!item?.file || !latest.current.upload || item.retryable === false) return;\n      const next: FileUploadItem = { ...item, status: \"uploading\", progress: 0, error: undefined };\n      commit(list.current.map((f) => (f.id === id ? next : f)));\n      start(next);\n    },\n    [commit, start],\n  );\n\n  const clear = useCallback(() => {\n    for (const c of controllers.current.values()) c.abort();\n    controllers.current.clear();\n    commit([]);\n  }, [commit]);\n\n  const dismissRejection = useCallback(() => setRejection(null), []);\n\n  useEffect(() => {\n    const ctrl = controllers.current;\n    return () => {\n      for (const c of ctrl.values()) c.abort();\n    };\n  }, []);\n\n  return { items, add, remove, retry, clear, rejection: rejection?.message ?? null, dismissRejection, canRetry: !!upload, accept, maxSize };\n}\n\n/* ---------- The component -------------------------------------------------------- */\n\nexport type FileUploadProps = UseFileUploadOptions & {\n  /** A controller from `useFileUpload`, when your own code also adds files. Its options win. */\n  controller?: FileUploadController;\n  /** Allow picking several files at once. Default true. */\n  multiple?: boolean;\n  disabled?: boolean;\n  /** The dropzone's title. Default \"Drop files here\". */\n  title?: ReactNode;\n  /** The line under the title. Default: the accepted types and the size limit. */\n  hint?: ReactNode;\n  /** Names the file list. Default \"Files\". */\n  listLabel?: string;\n  /** Rendered after the list, inside the component, e.g. a footer with a Submit button. */\n  children?: ReactNode;\n  className?: string;\n};\n\n/**\n * A dropzone and the files dropped on it. Click, drop or paste to add; a drag anywhere on the\n * page wakes the zone, and over it the icon lifts and a soft light follows the pointer. Rows\n * rise in while the list's height springs, each with a progress bar on the smooth spring;\n * removed rows fade and the rest close the gap. Refused files are explained under the zone.\n */\nexport function FileUpload({\n  controller,\n  multiple = true,\n  disabled = false,\n  title = \"Drop files here\",\n  hint,\n  listLabel = \"Files\",\n  children,\n  className = \"\",\n  ...options\n}: FileUploadProps) {\n  const own = useFileUpload(options);\n  const ctrl = controller ?? own;\n  const { items, add, remove, retry, rejection, canRetry } = ctrl;\n  const reduced = useSafeReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const zoneRef = useRef<HTMLButtonElement>(null);\n  const glowRef = useRef<HTMLSpanElement>(null);\n  const removeRefs = useRef(new Map<string, HTMLButtonElement>());\n  const pendingFocus = useRef<string | \"zone\" | null>(null);\n  const [over, setOver] = useState<{ count: number; valid: boolean } | null>(null);\n  const pageDrag = usePageFileDrag(!disabled);\n  const depth = useRef(0);\n  const hintId = useId();\n  const errorId = useId();\n  const accept = controller ? controller.accept : options.accept;\n  const maxSize = controller ? controller.maxSize : options.maxSize;\n\n  const defaultHint = [accept ? describeAccept(accept) : null, maxSize ? `up to ${formatBytes(maxSize)}` : null].filter(Boolean).join(\", \");\n  const hintText = hint ?? (defaultHint ? defaultHint[0].toUpperCase() + defaultHint.slice(1) : \"Any file type\");\n\n  const inspect = (e: ReactDragEvent) => {\n    const files = [...e.dataTransfer.items].filter((i) => i.kind === \"file\");\n    // During a drag only types are readable (names arrive on drop), so `.ext` rules can't be judged yet.\n    const judgeable = accept ? accept.split(\",\").every((r) => !r.trim().startsWith(\".\")) : true;\n    const valid = !judgeable || files.every((f) => matchesAccept({ name: \"\", type: f.type }, accept));\n    return { count: files.length, valid };\n  };\n\n  const onDragEnter = (e: ReactDragEvent<HTMLButtonElement>) => {\n    if (disabled || !e.dataTransfer.types.includes(\"Files\")) return;\n    e.preventDefault();\n    depth.current++;\n    setOver(inspect(e));\n  };\n  const onDragOver = (e: ReactDragEvent<HTMLButtonElement>) => {\n    if (disabled || !e.dataTransfer.types.includes(\"Files\")) return;\n    e.preventDefault();\n    e.dataTransfer.dropEffect = \"copy\";\n    const zone = zoneRef.current;\n    const glow = glowRef.current;\n    if (zone && glow) {\n      const r = zone.getBoundingClientRect();\n      glow.style.setProperty(\"--x\", `${e.clientX - r.left}px`);\n      glow.style.setProperty(\"--y\", `${e.clientY - r.top}px`);\n    }\n  };\n  const onDragLeave = () => {\n    depth.current = Math.max(0, depth.current - 1);\n    if (depth.current === 0) setOver(null);\n  };\n  const onDrop = (e: ReactDragEvent<HTMLButtonElement>) => {\n    if (disabled) return;\n    e.preventDefault();\n    depth.current = 0;\n    setOver(null);\n    const files = [...e.dataTransfer.files];\n    if (files.length) add(multiple ? files : files.slice(0, 1));\n  };\n  const onPaste = (e: ReactClipboardEvent) => {\n    if (disabled) return;\n    const files = [...e.clipboardData.files];\n    if (!files.length) return;\n    e.preventDefault();\n    add(multiple ? files : files.slice(0, 1));\n  };\n\n  const onRemove = (id: string) => {\n    const at = items.findIndex((f) => f.id === id);\n    const neighbour = items[at + 1] ?? items[at - 1];\n    // Keep keyboard focus in the list: the next row's remove button, else the dropzone.\n    if (document.activeElement === removeRefs.current.get(id)) pendingFocus.current = neighbour ? neighbour.id : \"zone\";\n    remove(id);\n  };\n  useEffect(() => {\n    const target = pendingFocus.current;\n    if (!target) return;\n    pendingFocus.current = null;\n    (target === \"zone\" ? zoneRef.current : removeRefs.current.get(target))?.focus();\n  }, [items]);\n\n  const active = over !== null;\n  const invalid = active && !over.valid;\n  const titleText = invalid\n    ? \"Not a supported type\"\n    : active\n      ? over.count > 1\n        ? `Drop ${over.count} files`\n        : \"Drop to upload\"\n      : title;\n\n  return (\n    <div onPaste={onPaste} className={`flex min-w-0 flex-col ${className}`}>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        tabIndex={-1}\n        aria-hidden\n        className=\"sr-only\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(e) => {\n          if (e.target.files?.length) add(e.target.files);\n          e.target.value = \"\";\n        }}\n      />\n      <button\n        ref={zoneRef}\n        type=\"button\"\n        disabled={disabled}\n        aria-describedby={`${hintId} ${errorId}`}\n        onClick={() => inputRef.current?.click()}\n        onDragEnter={onDragEnter}\n        onDragOver={onDragOver}\n        onDragLeave={onDragLeave}\n        onDrop={onDrop}\n        data-over={active || undefined}\n        data-invalid={invalid || undefined}\n        data-page-drag={(pageDrag && !active) || undefined}\n        className={`group relative isolate flex min-h-32 w-full flex-col items-center justify-center gap-3 overflow-clip rounded-control border border-dashed px-4 py-5 text-center ${pressWideNoScale} ${\n          disabled\n            ? \"cursor-not-allowed border-line bg-surface-sunken text-text-disabled\"\n            : `cursor-pointer ${\n                invalid\n                  ? \"border-danger bg-danger-subtle\"\n                  : active\n                    ? \"border-text bg-surface-hover\"\n                    : pageDrag\n                      ? \"border-text-muted bg-surface-hover\"\n                      : \"border-line-strong bg-surface hover-fine:bg-surface-hover\"\n              }`\n        }`}\n      >\n        <span\n          ref={glowRef}\n          aria-hidden\n          className={`pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(160px_circle_at_var(--x,50%)_var(--y,50%),color-mix(in_oklch,var(--eased-text)_7%,transparent),transparent_70%)] transition-opacity duration-200 ease-standard ${\n            active && !invalid ? \"opacity-100\" : \"opacity-0\"\n          }`}\n        />\n        <UploadGlyph lifted={active} pageDrag={pageDrag} invalid={invalid} reduced={reduced} disabled={disabled} />\n        <span className=\"flex min-w-0 flex-col items-center gap-0.5\">\n          <span className={`text-ui font-medium ${disabled ? \"\" : invalid ? \"text-danger-text\" : \"text-text\"}`}>\n            <TextSwap swapKey={typeof titleText === \"string\" ? titleText : active ? \"active\" : \"rest\"}>\n              {active ? (\n                titleText\n              ) : (\n                <span>\n                  {titleText} <span className=\"font-normal text-text-muted\">or</span>{\" \"}\n                  <span className=\"underline decoration-line-strong underline-offset-2 group-hover-fine:decoration-text\">browse</span>\n                </span>\n              )}\n            </TextSwap>\n          </span>\n          <span id={hintId} className={`text-ui-sm ${disabled ? \"\" : \"text-text-muted\"}`}>\n            {hintText}\n          </span>\n        </span>\n      </button>\n\n      {/* Always mounted, so the message is announced when it appears. */}\n      <div id={errorId} role=\"status\" aria-live=\"polite\">\n        <AutoHeight open={!!rejection} innerClassName=\"pt-3\">\n          <p className=\"flex items-start gap-1.5 text-ui-sm text-danger-text\">\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden className=\"mt-0.5 size-4 shrink-0\">\n              <circle cx=\"8\" cy=\"8\" r=\"6.25\" />\n              <path d=\"M8 5v3.5M8 11h.01\" />\n            </svg>\n            <span className=\"min-w-0 text-pretty\">{rejection}</span>\n          </p>\n        </AutoHeight>\n      </div>\n\n      <AutoHeight className=\"mt-1.5\">\n        <ul aria-label={listLabel} className=\"relative flex flex-col\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {items.map((item) => (\n              <motion.li\n                key={item.id}\n                layout={reduced ? false : \"position\"}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98, filter: `blur(${blur.subtle})` }}\n                animate={{\n                  opacity: 1,\n                  y: 0,\n                  scale: 1,\n                  filter: \"blur(0px)\",\n                  transition: reduced ? reducedFade : { ...snappy, opacity: enter, filter: enter },\n                }}\n                exit={{ opacity: 0, scale: reduced ? 1 : 0.98, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle})`, transition: reduced ? reducedFade : exitFast }}\n                transition={reduced ? instant : smooth}\n                className=\"w-full border-line-subtle not-first:border-t\"\n              >\n                <FileRow\n                  item={item}\n                  reduced={reduced}\n                  canRetry={canRetry && item.retryable !== false && !!item.file}\n                  onRemove={() => onRemove(item.id)}\n                  onRetry={() => retry(item.id)}\n                  removeRef={(el) => {\n                    if (el) removeRefs.current.set(item.id, el);\n                    else removeRefs.current.delete(item.id);\n                  }}\n                />\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n      </AutoHeight>\n      {children}\n    </div>\n  );\n}\n\n/** Colour transitions without the press scale: the zone is wider than any press should move. */\nconst pressWideNoScale =\n  \"[transition:background-color_150ms_var(--eased-ease-standard),border-color_150ms_var(--eased-ease-standard),color_150ms_var(--eased-ease-standard),box-shadow_var(--eased-duration-focus)_var(--eased-ease-standard)] focus-ring\";\n\n/* ---------- Parts --------------------------------------------------------------- */\n\n/** True while files are being dragged anywhere over the page, so the zone can wake up before it is reached. */\nfunction usePageFileDrag(enabled: boolean) {\n  const [dragging, setDragging] = useState(false);\n  useEffect(() => {\n    if (!enabled) return;\n    let depth = 0;\n    const isFiles = (e: DragEvent) => e.dataTransfer?.types.includes(\"Files\") ?? false;\n    const onEnter = (e: DragEvent) => {\n      if (!isFiles(e)) return;\n      depth++;\n      setDragging(true);\n    };\n    const onLeave = (e: DragEvent) => {\n      if (!isFiles(e)) return;\n      depth = Math.max(0, depth - 1);\n      if (depth === 0) setDragging(false);\n    };\n    const reset = () => {\n      depth = 0;\n      setDragging(false);\n    };\n    window.addEventListener(\"dragenter\", onEnter);\n    window.addEventListener(\"dragleave\", onLeave);\n    window.addEventListener(\"drop\", reset);\n    window.addEventListener(\"dragend\", reset);\n    return () => {\n      window.removeEventListener(\"dragenter\", onEnter);\n      window.removeEventListener(\"dragleave\", onLeave);\n      window.removeEventListener(\"drop\", reset);\n      window.removeEventListener(\"dragend\", reset);\n    };\n  }, [enabled]);\n  return enabled && dragging;\n}\n\nfunction UploadGlyph({ lifted, pageDrag, invalid, reduced, disabled }: { lifted: boolean; pageDrag: boolean; invalid: boolean; reduced: boolean; disabled: boolean }) {\n  const t = reduced ? instant : snappy;\n  return (\n    <motion.span\n      aria-hidden\n      animate={{ scale: lifted && !invalid ? 1.06 : 1, y: lifted && !invalid ? -2 : 0 }}\n      transition={t}\n      className={`relative flex size-10 items-center justify-center rounded-control border transition-colors duration-150 ease-standard ${\n        disabled\n          ? \"border-line bg-surface-sunken\"\n          : invalid\n            ? \"border-danger bg-surface text-danger-text\"\n            : lifted || pageDrag\n              ? \"border-line-strong bg-surface text-text shadow-resting\"\n              : \"border-line bg-surface text-text-secondary shadow-resting\"\n      }`}\n    >\n      <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-5\">\n        <path d=\"M3.5 12.5v2c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2v-2\" />\n        <motion.g animate={{ y: lifted && !invalid ? -1.5 : pageDrag ? -0.75 : 0 }} transition={t}>\n          <path d=\"M10 13V4M6.5 7.5 10 4l3.5 3.5\" />\n        </motion.g>\n      </svg>\n    </motion.span>\n  );\n}\n\ntype FileRowProps = {\n  item: FileUploadItem;\n  reduced: boolean;\n  canRetry: boolean;\n  onRemove: () => void;\n  onRetry: () => void;\n  removeRef: (el: HTMLButtonElement | null) => void;\n};\n\nfunction FileRow({ item, reduced, canRetry, onRemove, onRetry, removeRef }: FileRowProps) {\n  const { status } = item;\n  // Rows that were already done when they mounted don't replay the check.\n  const [initialStatus] = useState(status);\n  const progress = item.progress ?? 0;\n  const percent = Math.round(progress * 100);\n  const sizeText = formatBytes(item.size);\n  const spoken =\n    status === \"uploading\" ? `${item.name}, uploading, ${percent}%` : status === \"error\" ? `${item.name}, failed: ${item.error ?? \"upload failed\"}` : `${item.name}, ${sizeText}, uploaded`;\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={spoken}\n      className=\"grid h-14 grid-cols-[2.25rem_minmax(0,1fr)_4.5rem] items-center gap-3\"\n    >\n      <span\n        aria-hidden\n        className={`flex size-9 items-center justify-center rounded-control-sm text-ui-2xs font-medium tracking-caps transition-colors duration-150 ease-standard select-none ${\n          status === \"error\" ? \"bg-danger-subtle text-danger-text\" : \"bg-surface-sunken text-text-secondary\"\n        }`}\n      >\n        {status === \"error\" ? (\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" className=\"size-4\">\n            <circle cx=\"8\" cy=\"8\" r=\"6.25\" />\n            <path d=\"M8 5v3.5M8 11h.01\" />\n          </svg>\n        ) : (\n          typeLabel(item)\n        )}\n      </span>\n      <div className=\"flex min-w-0 flex-col gap-1\">\n        <p className=\"truncate text-ui-sm font-medium text-text\" title={item.name}>\n          {item.name}\n        </p>\n        <div className=\"relative h-4 min-w-0\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {status === \"uploading\" ? (\n              <motion.div\n                key=\"progress\"\n                className=\"absolute inset-0 flex items-center gap-2\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1, transition: reduced ? reducedFade : enter }}\n                exit={{ opacity: 0, transition: reduced ? reducedFade : exitFast }}\n              >\n                <span className=\"relative h-1 min-w-0 flex-1 overflow-clip rounded-pill bg-surface-active\">\n                  <motion.span\n                    className=\"absolute inset-0 origin-left rounded-pill bg-text\"\n                    initial={false}\n                    animate={{ scaleX: progress }}\n                    transition={reduced ? instant : smooth}\n                  />\n                </span>\n                <span className=\"w-8 shrink-0 text-right text-ui-xs text-text-muted tabular-nums\">{percent}%</span>\n              </motion.div>\n            ) : (\n              <motion.p\n                key={status}\n                className={`absolute inset-0 flex items-center gap-1.5 truncate text-ui-xs ${status === \"error\" ? \"text-danger-text\" : \"text-text-muted\"}`}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${blur.soft})` }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\", transition: reduced ? reducedFade : { ...snappy, opacity: enter, filter: enter } }}\n                exit={{ opacity: 0, transition: reduced ? reducedFade : exitFast }}\n              >\n                {status === \"error\" ? (\n                  <span className=\"truncate\">{item.error ?? \"Upload failed\"}</span>\n                ) : (\n                  <>\n                    <span className=\"tabular-nums\">{sizeText}</span>\n                    <CheckDraw draw={!reduced && initialStatus !== \"done\"} />\n                  </>\n                )}\n              </motion.p>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n      <div className=\"-mr-2 flex items-center justify-end gap-0.5\">\n        {status === \"error\" && canRetry && (\n          <IconButton label={`Retry ${item.name}`} onClick={onRetry}>\n            <path d=\"M12.5 8a4.5 4.5 0 1 1-1.3-3.2M12.5 3v2.5H10\" />\n          </IconButton>\n        )}\n        <IconButton ref={removeRef} label={status === \"uploading\" ? `Cancel ${item.name}` : `Remove ${item.name}`} onClick={onRemove}>\n          <path d=\"M5 5l6 6M11 5l-6 6\" />\n        </IconButton>\n      </div>\n    </div>\n  );\n}\n\nfunction IconButton({ label, onClick, children, ref }: { label: string; onClick: () => void; children: ReactNode; ref?: (el: HTMLButtonElement | null) => void }) {\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-control-sm text-text-muted hover-fine:bg-surface-hover hover-fine:text-text active:bg-surface-active ${pressIcon}`}\n    >\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"size-4\">\n        {children}\n      </svg>\n    </button>\n  );\n}\n\n/** A check that draws itself once, when the upload lands. */\nfunction CheckDraw({ draw }: { draw: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.75} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"size-3.5 shrink-0 text-success-text\">\n      <motion.path\n        d=\"m3.5 8.5 3 3 6-7\"\n        initial={draw ? { pathLength: 0 } : false}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.3, ease: ease.enter, delay: 0.08 }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/file-upload/file-upload.tsx"
    }
  ],
  "meta": {
    "industries": [
      "saas",
      "legal",
      "real-estate",
      "hr",
      "health"
    ]
  },
  "docs": "Import FileUpload and useFileUpload from components/eased/file-upload/file-upload. Pass an upload function to report progress; the network call is yours. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}