{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload-list",
  "title": "Upload List",
  "description": "The list of files under a dropzone or file picker — one row each with the file name, its size, a progress bar while it uploads, an error with a retry button when it fails, and an X to drop it from the queue. Use it on any screen that accepts files: an attachment picker, an image or avatar upload, a CSV/spreadsheet import step, a document or PDF upload, a bulk media drop, or an import wizard. Common asks it answers: \"file upload list\", \"upload queue\", \"show selected files with progress\", \"file list with remove button\", \"upload progress bar per file\", \"attachment list\", \"retry failed upload\", \"Dropbox/Gmail-style upload rows\". shadcn/ui ships nothing that tracks an upload: its attachment component renders a file that is already attached — media, title, actions — with no status, no progress and no retry, and its progress primitive is a single bar with no notion of a file, so the row layout, the byte formatting, the per-file progress and the failure affordance are hand-rolled every time. It pairs with the file-dropzone component, which hands you a File[] and deliberately stops there: this is the half that shows what happened to those files. Pass an `items` array of `{ id, name, size?, status, progress?, error? }` where status is pending | uploading | done | error; omit `progress` and the bar goes indeterminate for uploads with no known length, and an empty array renders nothing so you can mount it unconditionally next to your queue state. It is presentational on purpose and never uploads anything — you keep the requests, the concurrency, the cancellation and the retry policy, and pass `onRemove`/`onRetry` to get the buttons. Accessibility is where a queue usually goes wrong and this one is built around it: progress sits in a role=progressbar, which is not a live region, so a file crawling from 1% to 100% does not narrate every tick; instead an always-mounted role=status region announces only the rows that just finished or just failed, batched into one message per change; the first render is treated as the starting state, so a list that mounts with finished rows stays silent; and every remove/retry button carries the file name in its accessible name, because a column of buttons all called \"Remove\" is unusable without sight of the row. Sizes are formatted to KB/MB/GB with tabular numerals, long names truncate with a title tooltip, and it is styled with shadcn tokens (muted-foreground, destructive, primary, accent, ring) so it follows light and dark themes; lucide-react is the only dependency. Distinct from save-status, which is a one-line indicator for a single background save, and from progress-ring, which is one circular meter: this is the multi-file queue.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/upload-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleAlert, File as FileIcon, RotateCw, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** One row of the queue. `id` is what removals, retries and announcements key off. */\nexport interface UploadItem {\n  id: string\n  /** File name shown in the row. Long names are truncated with a title tooltip. */\n  name: string\n  /** Size in bytes. Omit it when you don't know it and the row just hides the size. */\n  size?: number\n  status: \"pending\" | \"uploading\" | \"done\" | \"error\"\n  /** Percentage 0–100 while uploading. Omit it for an indeterminate bar. */\n  progress?: number\n  /** Failure reason shown in place of the meta line, e.g. \"File is too large\". */\n  error?: string\n}\n\ninterface UploadListProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** The queue. Render it straight from your own upload state — an empty list renders nothing. */\n  items: UploadItem[]\n  /** Shows a remove button on every row. Omit it and no button is rendered. */\n  onRemove?: (item: UploadItem) => void\n  /** Shows a retry button on failed rows. Omit it and no button is rendered. */\n  onRetry?: (item: UploadItem) => void\n  /** Text for a queued row that hasn't started. */\n  pendingLabel?: string\n  /** Text for a finished row. */\n  doneLabel?: string\n  /** Fallback text for a failed row with no `error` message. */\n  errorLabel?: string\n  /** Accessible name of the retry button (the file name is appended). */\n  retryLabel?: string\n  /** Accessible name of the remove button (the file name is appended). */\n  removeLabel?: string\n}\n\nconst UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]\n\nfunction formatBytes(bytes: number) {\n  if (!Number.isFinite(bytes) || bytes < 0) return \"\"\n  let value = bytes\n  let unit = 0\n  while (value >= 1024 && unit < UNITS.length - 1) {\n    value /= 1024\n    unit += 1\n  }\n  return `${unit === 0 || value >= 10 ? Math.round(value) : value.toFixed(1)} ${UNITS[unit]}`\n}\n\nfunction clampPct(n: number) {\n  return Math.min(100, Math.max(0, Math.round(n)))\n}\n\n/**\n * The list of files under a dropzone or file picker: one row each with its\n * name, size, upload progress, and a way to remove it or retry a failure.\n *\n * It is a presentation component on purpose — it never uploads anything. You\n * own the requests (and therefore the cancellation, concurrency and retry\n * policy) and hand it the current queue; a component that owned the transfer\n * would have to guess your endpoint, auth and progress source.\n *\n * Accessibility notes, since this is where a queue usually goes wrong:\n *\n * - Progress lives in a `role=\"progressbar\"`, which is *not* a live region, so\n *   a file crawling from 1% to 100% doesn't narrate every tick.\n * - Terminal transitions are what a user actually needs to hear, so the\n *   always-mounted `role=\"status\"` region announces only rows that just landed\n *   on done or error — batched into one message per change, never per percent.\n * - The first render is treated as the starting state rather than a set of\n *   transitions; a list that mounts with finished rows stays quiet.\n * - Row buttons carry the file name in their accessible name, because a column\n *   of buttons all called \"Remove\" is unusable out of visual context.\n */\nexport function UploadList({\n  items,\n  onRemove,\n  onRetry,\n  pendingLabel = \"Waiting\",\n  doneLabel = \"Uploaded\",\n  errorLabel = \"Upload failed\",\n  retryLabel = \"Retry\",\n  removeLabel = \"Remove\",\n  className,\n  ...props\n}: UploadListProps) {\n  const [announce, setAnnounce] = React.useState(\"\")\n  const seen = React.useRef(new Map<string, UploadItem[\"status\"]>())\n  const mounted = React.useRef(false)\n\n  React.useEffect(() => {\n    const before = seen.current\n    seen.current = new Map(items.map((item) => [item.id, item.status]))\n\n    // An empty queue unmounts the region below, so whatever it last said would come back\n    // mounted-with-text on the next batch — and the batch after an emptied queue is a new\n    // starting state, not a set of transitions: without this, re-opening a picker on files\n    // that are already `done` announces them as if they had just finished uploading.\n    if (items.length === 0) {\n      mounted.current = false\n      setAnnounce(\"\")\n      return\n    }\n\n    if (!mounted.current) {\n      mounted.current = true\n      return\n    }\n\n    const done: string[] = []\n    const failed: string[] = []\n    for (const item of items) {\n      if (before.get(item.id) === item.status) continue\n      if (item.status === \"done\") done.push(item.name)\n      else if (item.status === \"error\") failed.push(item.name)\n    }\n    if (!done.length && !failed.length) return\n\n    const parts: string[] = []\n    if (done.length) {\n      parts.push(\n        done.length === 1 ? `${done[0]} uploaded` : `${done.length} files uploaded`\n      )\n    }\n    if (failed.length) {\n      parts.push(\n        failed.length === 1\n          ? `${failed[0]} failed to upload`\n          : `${failed.length} files failed to upload`\n      )\n    }\n    setAnnounce(parts.join(\", \"))\n  }, [items])\n\n  if (!items.length) return null\n\n  return (\n    <div className={cn(\"w-full\", className)} {...props}>\n      <ul className=\"divide-y rounded-lg border\">\n        {items.map((item) => {\n          const indeterminate = item.progress === undefined\n          const pct = item.progress === undefined ? 0 : clampPct(item.progress)\n          const size = item.size === undefined ? \"\" : formatBytes(item.size)\n          const meta =\n            item.status === \"error\"\n              ? item.error || errorLabel\n              : item.status === \"done\"\n                ? doneLabel\n                : item.status === \"pending\"\n                  ? pendingLabel\n                  : indeterminate\n                    ? null\n                    : `${pct}%`\n\n          return (\n            <li key={item.id} className=\"flex items-start gap-3 px-3 py-2.5\">\n              {item.status === \"done\" ? (\n                <Check\n                  className=\"mt-0.5 h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400\"\n                  aria-hidden=\"true\"\n                />\n              ) : item.status === \"error\" ? (\n                <CircleAlert\n                  className=\"mt-0.5 h-4 w-4 shrink-0 text-destructive\"\n                  aria-hidden=\"true\"\n                />\n              ) : (\n                <FileIcon\n                  className=\"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground\"\n                  aria-hidden=\"true\"\n                />\n              )}\n\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"truncate text-sm text-foreground\" title={item.name}>\n                  {item.name}\n                </div>\n                <div\n                  className={cn(\n                    \"mt-0.5 flex items-center gap-1.5 text-xs\",\n                    item.status === \"error\"\n                      ? \"text-destructive\"\n                      : \"text-muted-foreground\"\n                  )}\n                >\n                  {size ? <span className=\"tabular-nums\">{size}</span> : null}\n                  {size && meta ? <span aria-hidden=\"true\">·</span> : null}\n                  {meta ? <span className=\"tabular-nums\">{meta}</span> : null}\n                </div>\n\n                {item.status === \"uploading\" ? (\n                  <div\n                    role=\"progressbar\"\n                    aria-valuemin={0}\n                    aria-valuemax={100}\n                    aria-valuenow={indeterminate ? undefined : pct}\n                    aria-label={`Uploading ${item.name}`}\n                    className=\"mt-2 h-1 w-full overflow-hidden rounded-full bg-muted-foreground/20\"\n                  >\n                    <div\n                      className={cn(\n                        \"h-full rounded-full bg-primary\",\n                        indeterminate\n                          ? // No total to fill toward, so a pulsing partial bar\n                            // reads as work-in-progress without a custom keyframe.\n                            \"w-1/3 animate-pulse\"\n                          : \"transition-[width] duration-300 ease-out\"\n                      )}\n                      style={indeterminate ? undefined : { width: `${pct}%` }}\n                    />\n                  </div>\n                ) : null}\n              </div>\n\n              <div className=\"flex shrink-0 items-center gap-1\">\n                {item.status === \"error\" && onRetry ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => onRetry(item)}\n                    aria-label={`${retryLabel} ${item.name}`}\n                    className=\"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n                  >\n                    <RotateCw className=\"h-4 w-4\" aria-hidden=\"true\" />\n                  </button>\n                ) : null}\n                {onRemove ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => onRemove(item)}\n                    aria-label={`${removeLabel} ${item.name}`}\n                    className=\"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n                  >\n                    <X className=\"h-4 w-4\" aria-hidden=\"true\" />\n                  </button>\n                ) : null}\n              </div>\n            </li>\n          )\n        })}\n      </ul>\n      <span role=\"status\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}