{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload-queue",
  "type": "registry:ui",
  "title": "Upload queue",
  "description": "Turns file intake, per-item progress, retry, and completion into one compact flow.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "feedback"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/upload-queue/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Per-file upload flow that resolves into completion",
    "sceneFamily": "product-mono",
    "motionRole": "lively",
    "primaryState": "Upload queue advancing item by item",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/upload-queue.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/upload-queue.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type DragEvent,\n  type MouseEvent as ReactMouseEvent,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst ROW = { type: \"spring\", stiffness: 420, damping: 38, mass: 0.7 } as const;\nconst FILL = { type: \"spring\", stiffness: 240, damping: 34, mass: 0.85 } as const;\nconst LEAVE = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type UploadStatus = \"queued\" | \"uploading\" | \"complete\" | \"error\";\n\nexport type UploadItem = {\n  id: string;\n  name: string;\n  size?: number;\n  status: UploadStatus;\n  progress?: number;\n  error?: string;\n};\n\nexport type UploadQueueProps = {\n  items: readonly UploadItem[];\n  onFiles: (files: File[]) => void;\n  onRemove?: (id: string) => void;\n  onRetry?: (id: string) => void;\n  label?: string;\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  copy?: Partial<UploadQueueCopy>;\n  className?: string;\n};\n\nexport type UploadQueueCopy = {\n  drop: (remaining: number) => string;\n  full: string;\n  unsupported: string;\n  limit: (remaining: number) => string;\n  choose: string;\n  queue: string;\n  complete: string;\n  failed: string;\n  uploading: string;\n  queued: string;\n  retry: string;\n  remove: string;\n  progress: string;\n  summary: (complete: number, total: number) => string;\n};\n\ntype UploadRejection =\n  | { unsupported: boolean; capacity: number | null }\n  | null;\n\nconst defaultCopy: UploadQueueCopy = {\n  drop: (remaining) => `Drop here or choose up to ${remaining}`,\n  full: \"Queue is full\",\n  unsupported: \"This file type is not supported\",\n  limit: (remaining) => `Only ${remaining} more ${remaining === 1 ? \"file\" : \"files\"} can be added`,\n  choose: \"Choose\",\n  queue: \"Upload queue\",\n  complete: \"Complete\",\n  failed: \"Upload failed\",\n  uploading: \"Uploading\",\n  queued: \"Queued\",\n  retry: \"Retry\",\n  remove: \"Remove\",\n  progress: \"Upload progress for\",\n  summary: (complete, total) => `${complete} of ${total} uploads complete`,\n};\n\nfunction formatBytes(value?: number) {\n  if (value === undefined) return \"\";\n  if (value < 1024) return `${value} B`;\n  if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;\n  return `${(value / 1024 / 1024).toFixed(1)} MB`;\n}\n\nfunction accepts(file: File, accept?: string) {\n  if (!accept?.trim()) return true;\n  const type = file.type.toLowerCase();\n  const name = file.name.toLowerCase();\n  return accept.split(\",\").some((entry) => {\n    const rule = entry.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\nfunction useAnimationActivity<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [active, setActive] = useState(true);\n\n  useEffect(() => {\n    const node = ref.current;\n    if (!node || typeof IntersectionObserver === \"undefined\") return;\n    let visible = !document.hidden;\n    let intersecting = true;\n    const update = () => setActive(visible && intersecting);\n    const observer = new IntersectionObserver(([entry]) => {\n      intersecting = entry.isIntersecting;\n      update();\n    });\n    const onVisibility = () => {\n      visible = !document.hidden;\n      update();\n    };\n    observer.observe(node);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, []);\n\n  return { ref, active };\n}\n\nexport function UploadQueue({\n  items,\n  onFiles,\n  onRemove,\n  onRetry,\n  label = \"Upload files\",\n  accept,\n  multiple = true,\n  maxFiles = 8,\n  copy: copyOverrides,\n  className = \"\",\n}: UploadQueueProps) {\n  const copy = { ...defaultCopy, ...copyOverrides };\n  const id = useId();\n  const reduced = useReducedMotion() === true;\n  const [dragging, setDragging] = useState(false);\n  const [rejection, setRejection] = useState<UploadRejection>(null);\n  const { ref, active } = useAnimationActivity<HTMLDivElement>();\n  const input = useRef<HTMLInputElement>(null);\n  const chooseButton = useRef<HTMLButtonElement>(null);\n  const rowRefs = useRef(new Map<string, HTMLLIElement>());\n  const pendingFocus = useRef<{\n    id: string;\n    index: number;\n    trigger: HTMLButtonElement;\n  } | null>(null);\n  const remaining = Math.max(0, maxFiles - items.length);\n\n  useLayoutEffect(() => {\n    const pending = pendingFocus.current;\n    if (!pending) return;\n\n    const triggerStillPresent = pending.trigger.isConnected && ref.current?.contains(pending.trigger);\n    const itemStillPresent = items.some((item) => item.id === pending.id);\n    if (triggerStillPresent && itemStillPresent) {\n      if (document.activeElement !== pending.trigger) pendingFocus.current = null;\n      return;\n    }\n\n    pendingFocus.current = null;\n    const activeElement = document.activeElement;\n    if (\n      activeElement instanceof HTMLElement &&\n      activeElement !== document.body &&\n      activeElement !== document.documentElement &&\n      activeElement !== pending.trigger\n    ) {\n      return;\n    }\n\n    const candidateIds = [\n      items.some((item) => item.id === pending.id) ? pending.id : undefined,\n      items[pending.index]?.id,\n      items[pending.index - 1]?.id,\n    ].filter((id, index, values): id is string => Boolean(id) && values.indexOf(id) === index);\n\n    for (const id of candidateIds) {\n      const action = rowRefs.current.get(id)?.querySelector<HTMLButtonElement>(\"button:not([disabled])\");\n      if (action) {\n        action.focus({ preventScroll: true });\n        return;\n      }\n    }\n    chooseButton.current?.focus({ preventScroll: true });\n  }, [items, ref]);\n\n  const requestRowAction = (\n    event: ReactMouseEvent<HTMLButtonElement>,\n    id: string,\n    index: number,\n    action: (id: string) => void,\n  ) => {\n    pendingFocus.current = document.activeElement === event.currentTarget\n      ? { id, index, trigger: event.currentTarget }\n      : null;\n    action(id);\n  };\n\n  const submitFiles = (source: FileList | null) => {\n    if (!source || remaining === 0) return;\n    const sourceFiles = Array.from(source);\n    const acceptedFiles = sourceFiles.filter((file) => accepts(file, accept));\n    const capacity = multiple ? remaining : Math.min(1, remaining);\n    const unsupported = acceptedFiles.length < sourceFiles.length;\n    const exceedsCapacity = acceptedFiles.length > capacity;\n    setRejection(\n      unsupported || exceedsCapacity\n        ? { unsupported, capacity: exceedsCapacity ? capacity : null }\n        : null,\n    );\n    const files = acceptedFiles\n      .slice(0, capacity);\n    if (files.length > 0) onFiles(files);\n  };\n\n  const onInput = (event: ChangeEvent<HTMLInputElement>) => {\n    submitFiles(event.target.files);\n    event.target.value = \"\";\n  };\n\n  const onDrop = (event: DragEvent<HTMLDivElement>) => {\n    event.preventDefault();\n    setDragging(false);\n    submitFiles(event.dataTransfer.files);\n  };\n\n  return (\n    <div ref={ref} className={`w-full max-w-[430px] ${className}`}>\n      <div\n        data-upload-drop-zone\n        onDragEnter={(event) => {\n          event.preventDefault();\n          setDragging(true);\n        }}\n        onDragOver={(event) => event.preventDefault()}\n        onDragLeave={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);\n        }}\n        onDrop={onDrop}\n        className={`flex min-h-16 items-center gap-3 rounded-[10px] border border-dashed px-3 py-2.5 transition-[background-color,border-color] duration-150 ${\n          dragging\n            ? \"border-neutral-950 bg-neutral-100 dark:border-neutral-50 dark:bg-white/[0.08]\"\n            : \"border-neutral-300 bg-neutral-50 dark:border-white/[0.2] dark:bg-white/[0.04]\"\n        }`}\n      >\n        <span aria-hidden=\"true\" className=\"grid size-9 shrink-0 place-items-center rounded-[9px] bg-white text-neutral-500 shadow-[0_1px_3px_rgba(28,25,23,0.12)] dark:bg-[#252522] dark:text-neutral-400 dark:shadow-[0_1px_3px_rgba(0,0,0,0.45)]\">\n          <svg viewBox=\"0 0 18 18\" width=\"16\" height=\"16\" fill=\"none\">\n            <path d=\"M9 12V3m0 0L5.5 6.5M9 3l3.5 3.5M3 11v2.5A1.5 1.5 0 0 0 4.5 15h9a1.5 1.5 0 0 0 1.5-1.5V11\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n        </span>\n        <span className=\"min-w-0 flex-1\">\n          <strong className=\"block text-[13px] font-medium text-neutral-800 dark:text-neutral-100\">{label}</strong>\n          <span\n            role={rejection ? \"alert\" : undefined}\n            className={`mt-0.5 block text-[11.5px] ${\n              rejection\n                ? \"text-red-700 dark:text-red-400\"\n                : \"text-neutral-500 dark:text-neutral-300\"\n            }`}\n          >\n            {rejection\n              ? [\n                  rejection.unsupported ? copy.unsupported : null,\n                  rejection.capacity !== null ? copy.limit(rejection.capacity) : null,\n                ].filter((message): message is string => message !== null).join(\" \")\n              : remaining > 0\n                ? copy.drop(remaining)\n                : copy.full}\n          </span>\n        </span>\n        <input\n          ref={input}\n          id={id}\n          type=\"file\"\n          accept={accept}\n          multiple={multiple}\n          disabled={remaining === 0}\n          aria-label={label}\n          tabIndex={-1}\n          onChange={onInput}\n          className=\"sr-only\"\n        />\n        <button\n          ref={chooseButton}\n          type=\"button\"\n          disabled={remaining === 0}\n          onClick={() => input.current?.click()}\n          className=\"h-11 shrink-0 rounded-[9px] border border-neutral-200 bg-white px-3 text-[12.5px] font-medium text-neutral-700 outline-none transition-[background-color,border-color] duration-150 hover:bg-neutral-100 focus-visible:border-[#4568FF] disabled:cursor-not-allowed disabled:opacity-45 dark:border-white/[0.16] dark:bg-[#252522] dark:text-neutral-200 dark:hover:bg-white/10 dark:focus-visible:border-[#93B0FF]\"\n        >\n          {copy.choose}\n        </button>\n      </div>\n\n      <ul aria-label={copy.queue} className=\"mt-2 space-y-1.5\">\n        <AnimatePresence initial={false}>\n          {items.map((item, index) => (\n            <UploadRow\n              key={item.id}\n              item={item}\n              rowRef={(node) => {\n                if (node) rowRefs.current.set(item.id, node);\n                else rowRefs.current.delete(item.id);\n              }}\n              onRemove={onRemove ? (event) => requestRowAction(event, item.id, index, onRemove) : undefined}\n              onRetry={onRetry ? (event) => requestRowAction(event, item.id, index, onRetry) : undefined}\n              reduced={reduced}\n              animateIndeterminate={active}\n              copy={copy}\n            />\n          ))}\n        </AnimatePresence>\n      </ul>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {copy.summary(items.filter((item) => item.status === \"complete\").length, items.length)}\n      </span>\n    </div>\n  );\n}\n\nfunction UploadRow({\n  item,\n  rowRef,\n  onRemove,\n  onRetry,\n  reduced,\n  animateIndeterminate,\n  copy,\n}: {\n  item: UploadItem;\n  rowRef: (node: HTMLLIElement | null) => void;\n  onRemove?: (event: ReactMouseEvent<HTMLButtonElement>) => void;\n  onRetry?: (event: ReactMouseEvent<HTMLButtonElement>) => void;\n  reduced: boolean;\n  animateIndeterminate: boolean;\n  copy: UploadQueueCopy;\n}) {\n  const progress = Math.min(100, Math.max(0, item.progress ?? 0));\n  const complete = item.status === \"complete\";\n  const error = item.status === \"error\";\n  const unknown = item.status === \"uploading\" && item.progress === undefined;\n  const hasActions = (error && Boolean(onRetry)) || Boolean(onRemove);\n  const status = complete ? copy.complete : error ? item.error ?? copy.failed : item.status === \"uploading\" ? item.progress === undefined ? copy.uploading : `${Math.round(progress)}%` : copy.queued;\n\n  return (\n    <motion.li\n      ref={rowRef}\n      layout={!reduced}\n      initial={reduced ? { opacity: 0 } : { opacity: 0, transform: \"translate3d(0, 8px, 0)\" }}\n      animate={{ opacity: 1, transform: \"translate3d(0, 0, 0)\" }}\n      exit={reduced ? { opacity: 0 } : { opacity: 0, transform: \"translate3d(12px, 0, 0)\", transition: LEAVE }}\n      transition={reduced ? INSTANT : ROW}\n      className={`relative overflow-hidden rounded-[11px] border bg-white px-3 dark:bg-[#181818] ${\n        error ? \"border-red-600/50\" : \"border-neutral-200 dark:border-white/[0.16]\"\n      } ${complete ? \"py-0\" : \"py-1.5\"}`}\n    >\n      <div className=\"flex min-h-11 items-center gap-2.5\">\n        <span aria-hidden=\"true\" className={`grid size-8 shrink-0 place-items-center rounded-[8px] ${complete ? \"bg-emerald-600/10 text-emerald-700 dark:text-emerald-400\" : error ? \"bg-red-600/10 text-red-700 dark:text-red-400\" : \"bg-neutral-100 text-neutral-500 dark:bg-white/[0.08] dark:text-neutral-400\"}`}>\n          {complete ? (\n            <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\"><path d=\"m3.5 8.2 2.7 2.7 6.3-6\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" /></svg>\n          ) : (\n            <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\"><path d=\"M4 2.5h5l3 3v8H4z\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinejoin=\"round\" /><path d=\"M9 2.5v3h3\" stroke=\"currentColor\" strokeWidth=\"1.3\" /></svg>\n          )}\n        </span>\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"flex items-baseline justify-between gap-3\">\n            <strong className=\"truncate text-[12.5px] font-medium text-neutral-800 dark:text-neutral-100\">{item.name}</strong>\n            <span className=\"shrink-0 font-mono text-[10.5px] tabular-nums text-neutral-500 dark:text-neutral-400\">{status}</span>\n          </span>\n          {!complete ? <span className=\"mt-0.5 block text-[10.5px] text-neutral-600 dark:text-neutral-300\">{formatBytes(item.size)}</span> : null}\n        </span>\n        {hasActions ? (\n          <span className=\"flex shrink-0 items-center gap-0.5\">\n            {error && onRetry ? (\n              <button\n                type=\"button\"\n                onClick={onRetry}\n                aria-label={`${copy.retry} ${item.name}`}\n                className=\"h-11 shrink-0 rounded-[8px] px-2.5 text-[12px] font-medium text-neutral-700 outline-none transition-colors duration-150 hover:bg-neutral-100 focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-neutral-200 dark:hover:bg-white/10 dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n              >\n                {copy.retry}\n              </button>\n            ) : null}\n            {onRemove ? (\n              <button\n                type=\"button\"\n                onClick={onRemove}\n                aria-label={`${copy.remove} ${item.name}`}\n                className=\"grid size-11 shrink-0 place-items-center rounded-[8px] text-neutral-500 outline-none transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-800 focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-neutral-400 dark:hover:bg-white/10 dark:hover:text-neutral-100 dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n              >\n                <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" aria-hidden=\"true\"><path d=\"m4 4 8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" /></svg>\n              </button>\n            ) : null}\n          </span>\n        ) : null}\n      </div>\n      {!complete && !error ? (\n        <div\n          role=\"progressbar\"\n          aria-label={`${copy.progress} ${item.name}`}\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={unknown ? undefined : Math.round(progress)}\n          aria-valuetext={unknown ? copy.uploading : `${Math.round(progress)}%`}\n          className=\"absolute inset-x-3 bottom-0 h-[2px] overflow-hidden rounded-full bg-neutral-100 dark:bg-white/10\"\n        >\n          {unknown ? (\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"absolute inset-y-0 left-0 w-2/5 rounded-full bg-neutral-950 dark:bg-neutral-50\"\n              animate={reduced || !animateIndeterminate ? { transform: \"translate3d(0,0,0)\" } : { transform: [\"translate3d(-110%,0,0)\", \"translate3d(270%,0,0)\"] }}\n              transition={reduced || !animateIndeterminate ? INSTANT : { duration: 1.15, ease: \"linear\", repeat: Infinity }}\n            />\n          ) : (\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 origin-left rounded-full bg-neutral-950 dark:bg-neutral-50\"\n              initial={false}\n              animate={{ scaleX: progress / 100 }}\n              transition={reduced ? INSTANT : FILL}\n            />\n          )}\n        </div>\n      ) : null}\n    </motion.li>\n  );\n}\n"
    }
  ]
}
