{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload-1",
  "title": "Upload 1: upload manager",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An upload manager: a drop area that reacts to dragging, validation that explains each rejection, and a list where every file has its own state (waiting, uploading with progress, finishing, done, or failed with a reason and retry).",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/media.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/upload-1/upload-1.tsx",
      "content": "// Ballmac UI: Upload 1. https://ui.ballmac.com/blocks/upload-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, CircleCheck, Clock, FileText, Image as ImageIcon, Loader2, RotateCw, Sheet, UploadCloud, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Media } from \"@/components/ballmac/media\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Upload1Status = \"queued\" | \"uploading\" | \"processing\" | \"done\" | \"failed\"\n\ntype Upload1Item = {\n  id: string\n  name: string\n  /** Size in bytes. */\n  size: number\n  status: Upload1Status\n  /** From 0 to 1 while uploading. */\n  progress?: number\n  /** Why it failed. */\n  error?: string\n  /** A picture to show instead of the file-type tile, such as an object URL. */\n  preview?: string\n}\n\ntype Upload1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onChange\"> &\n  StateProps & {\n    title?: string\n    description?: string\n    /** Accepted types in the native input syntax: \".pdf\", \"image/*\", \"text/csv\". */\n    accept?: string\n    /** The accepted types as people read them (\"PNG, JPG, PDF or CSV\"). */\n    acceptLabel?: string\n    /** The most bytes one file may have. */\n    maxSize?: number\n    /** The most files at once. */\n    maxFiles?: number\n    /** How many files upload at the same time. */\n    concurrency?: number\n    /** Files shown when the page opens, for example earlier uploads. They are display only: they have no file to retry. */\n    defaultItems?: Upload1Item[]\n    /**\n     * Uploads one file. Call `report` with progress from 0 to 1; resolve when the server has the file, throw to mark it failed (the\n     * message is shown). `signal` aborts when the person cancels. Without it the block simulates an upload.\n     */\n    onUpload?: (file: File, report: (progress: number) => void, signal: AbortSignal) => Promise<void>\n    /** Called whenever the list changes. */\n    onItemsChange?: (items: Upload1Item[]) => void\n  }\n\nconst sample: Upload1Item[] = [\n  { id: \"s1\", name: \"Q3 report.pdf\", size: 2_410_000, status: \"done\" },\n  { id: \"s2\", name: \"team-photo.jpg\", size: 5_120_000, status: \"uploading\", progress: 0.62 },\n  { id: \"s3\", name: \"pricing-table.csv\", size: 18_400, status: \"processing\" },\n  { id: \"s4\", name: \"raw-footage.mov\", size: 48_300_000, status: \"failed\", error: \"Too large. The most is 10 MB.\" },\n  { id: \"s5\", name: \"logo-final.png\", size: 640_000, status: \"queued\" },\n]\n\nconst bytes = (n: number) => (n < 1000 ? `${n} B` : n < 1_000_000 ? `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)} KB` : `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)} MB`)\nconst extOf = (name: string) => (name.includes(\".\") ? name.split(\".\").pop()!.toUpperCase().slice(0, 4) : \"FILE\")\n\nfunction accepts(file: File, accept: string) {\n  const rules = accept.split(\",\").map((s) => s.trim().toLowerCase()).filter(Boolean)\n  if (!rules.length) return true\n  return rules.some((r) => (r.startsWith(\".\") ? file.name.toLowerCase().endsWith(r) : r.endsWith(\"/*\") ? file.type.startsWith(r.slice(0, -1)) : file.type === r))\n}\n\nconst sleep = (ms: number, signal: AbortSignal) =>\n  new Promise<void>((resolve, reject) => {\n    const t = setTimeout(resolve, ms)\n    signal.addEventListener(\"abort\", () => {\n      clearTimeout(t)\n      reject(new DOMException(\"Cancelled\", \"AbortError\"))\n    })\n  })\n\nfunction tileFor(item: Upload1Item) {\n  const ext = extOf(item.name)\n  if (/^(PNG|JPG|JPEG|GIF|WEBP|SVG|AVIF)$/.test(ext)) return { icon: <ImageIcon />, tone: \"bg-chart-4/20\" }\n  if (/^(CSV|XLS|XLSX)$/.test(ext)) return { icon: <Sheet />, tone: \"bg-chart-2/20\" }\n  return { icon: <FileText />, tone: \"bg-chart-1/20\" }\n}\n\nfunction UploadSkeleton() {\n  return (\n    <div className=\"grid gap-4\" aria-hidden=\"true\">\n      <Skeleton className=\"h-56 rounded-3xl\" />\n      {[0, 1, 2].map((i) => (\n        <Skeleton key={i} className=\"h-[4.5rem] rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * An upload manager: a drop area that wakes up when files are dragged over it, validation that explains each rejection, and a list where\n * every file has its own state (waiting, uploading with progress, finishing, done, or failed with the reason and a retry), with previews\n * of images, cancel, and a summary with Clear completed.\n */\nfunction Upload1({\n  title = \"Upload files\",\n  description = \"Add the files for this project. You can keep working while they upload.\",\n  accept = \"image/*,.pdf,.csv\",\n  acceptLabel = \"PNG, JPG, PDF or CSV\",\n  maxSize = 10_000_000,\n  maxFiles = 10,\n  concurrency = 2,\n  defaultItems = sample,\n  onUpload,\n  onItemsChange,\n  state = \"ready\",\n  onRetry,\n  errorState,\n  className,\n  ...props\n}: Upload1Props) {\n  const id = React.useId()\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const [items, setItems] = React.useState<Upload1Item[]>(defaultItems)\n  const [dragging, setDragging] = React.useState(false)\n  const [announce, setAnnounce] = React.useState(\"\")\n  const files = React.useRef(new Map<string, File>())\n  const controllers = React.useRef(new Map<string, AbortController>())\n  const queue = React.useRef<string[]>([])\n  const running = React.useRef(0)\n  const counter = React.useRef(0)\n  const dragDepth = React.useRef(0)\n  const unavailable = state === \"error\"\n  const shown = state === \"empty\" ? [] : items\n\n  const patch = React.useCallback((itemId: string, changes: Partial<Upload1Item>) => setItems((l) => l.map((i) => (i.id === itemId ? { ...i, ...changes } : i))), [])\n  React.useEffect(() => {\n    onItemsChange?.(items)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [items])\n  // release everything on unmount: stop uploads, and below free the preview pictures\n  React.useEffect(() => {\n    const ctrls = controllers.current\n    return () => {\n      ctrls.forEach((c) => c.abort())\n    }\n  }, [])\n  const previews = React.useRef(new Set<string>())\n  React.useEffect(() => {\n    const urls = previews.current\n    return () => urls.forEach((u) => URL.revokeObjectURL(u))\n  }, [])\n\n  const pump = React.useCallback(() => {\n    while (running.current < concurrency && queue.current.length) {\n      const itemId = queue.current.shift()!\n      const file = files.current.get(itemId)\n      if (!file) continue\n      running.current += 1\n      const controller = new AbortController()\n      controllers.current.set(itemId, controller)\n      patch(itemId, { status: \"uploading\", progress: 0, error: undefined })\n      const report = (p: number) => patch(itemId, p >= 1 ? { status: \"processing\", progress: 1 } : { progress: Math.max(0, p) })\n      ;(async () => {\n        try {\n          if (onUpload) await onUpload(file, report, controller.signal)\n          else {\n            for (let p = 0; p < 1; ) {\n              await sleep(120, controller.signal)\n              p = Math.min(1, p + 0.1 + (file.size % 7) / 100)\n              report(p)\n            }\n            await sleep(700, controller.signal)\n          }\n          patch(itemId, { status: \"done\", progress: 1 })\n          setAnnounce(`${file.name} uploaded`)\n        } catch (err) {\n          const cancelled = controller.signal.aborted\n          patch(itemId, { status: \"failed\", error: cancelled ? \"Cancelled.\" : err instanceof Error && err.message ? err.message : \"The upload failed. Check your connection and retry.\" })\n          setAnnounce(cancelled ? `${file.name} cancelled` : `${file.name} failed to upload`)\n        } finally {\n          controllers.current.delete(itemId)\n          running.current -= 1\n          pump()\n        }\n      })()\n    }\n  }, [concurrency, onUpload, patch])\n\n  function add(list: File[]) {\n    if (!list.length || unavailable) return\n    const next: Upload1Item[] = []\n    let room = Math.max(0, maxFiles - items.filter((i) => i.status !== \"failed\").length)\n    for (const file of list) {\n      const itemId = `${id}-${++counter.current}`\n      const base = { id: itemId, name: file.name, size: file.size }\n      if (!accepts(file, accept)) next.push({ ...base, status: \"failed\", error: `Not allowed. Use ${acceptLabel}.` })\n      else if (file.size > maxSize) next.push({ ...base, status: \"failed\", error: `Too large. The most is ${bytes(maxSize)}.` })\n      else if (room <= 0) next.push({ ...base, status: \"failed\", error: `Only ${maxFiles} files at a time.` })\n      else {\n        room -= 1\n        files.current.set(itemId, file)\n        let preview: string | undefined\n        if (file.type.startsWith(\"image/\")) {\n          preview = URL.createObjectURL(file)\n          previews.current.add(preview)\n        }\n        next.push({ ...base, status: \"queued\", progress: 0, preview })\n        queue.current.push(itemId)\n      }\n    }\n    setItems((l) => [...next, ...l])\n    setAnnounce(`${next.length} ${next.length === 1 ? \"file\" : \"files\"} added`)\n    pump()\n  }\n\n  function remove(item: Upload1Item) {\n    controllers.current.get(item.id)?.abort()\n    queue.current = queue.current.filter((q) => q !== item.id)\n    files.current.delete(item.id)\n    if (item.preview) {\n      URL.revokeObjectURL(item.preview)\n      previews.current.delete(item.preview)\n    }\n    setItems((l) => l.filter((i) => i.id !== item.id))\n  }\n\n  function retry(item: Upload1Item) {\n    if (!files.current.has(item.id)) return\n    patch(item.id, { status: \"queued\", progress: 0, error: undefined })\n    queue.current.push(item.id)\n    pump()\n  }\n\n  const total = shown.length\n  const done = shown.filter((i) => i.status === \"done\").length\n  const active = shown.some((i) => i.status === \"uploading\" || i.status === \"queued\" || i.status === \"processing\")\n  const overall = total ? shown.reduce((sum, i) => sum + (i.status === \"done\" || i.status === \"processing\" ? 1 : (i.progress ?? 0)), 0) / total : 0\n  const failed = shown.filter((i) => i.status === \"failed\").length\n\n  const drop = (\n    <div\n      role=\"group\"\n      aria-labelledby={`${id}-drop`}\n      onDragEnter={(e) => {\n        e.preventDefault()\n        dragDepth.current += 1\n        if (!unavailable) setDragging(true)\n      }}\n      onDragOver={(e) => e.preventDefault()}\n      onDragLeave={() => {\n        dragDepth.current = Math.max(0, dragDepth.current - 1)\n        if (!dragDepth.current) setDragging(false)\n      }}\n      onDrop={(e) => {\n        e.preventDefault()\n        dragDepth.current = 0\n        setDragging(false)\n        add([...e.dataTransfer.files])\n      }}\n      className={cn(\n        \"relative isolate flex flex-col items-center rounded-3xl border-2 border-dashed px-6 py-12 text-center transition-[border-color,background-color,transform] duration-200 motion-reduce:transition-none\",\n        dragging ? \"border-primary bg-accent/60 scale-[1.01] motion-reduce:scale-100\" : \"border-border hover:border-foreground/30 hover:bg-accent/20\",\n        unavailable && \"bg-muted/40 pointer-events-none border-border\"\n      )}\n    >\n      <span aria-hidden=\"true\" className={cn(\"bg-muted flex size-14 items-center justify-center rounded-2xl transition-transform duration-200 motion-reduce:transition-none [&_svg]:size-7\", dragging && \"bg-primary text-primary-foreground -translate-y-1 motion-reduce:translate-y-0\")}>\n        <UploadCloud />\n      </span>\n      <p id={`${id}-drop`} className=\"mt-5 text-lg font-semibold tracking-tight\">\n        {dragging ? \"Drop to upload\" : \"Drag files here\"}\n      </p>\n      <p className=\"text-muted-foreground mt-1 text-sm\">\n        or{\" \"}\n        <button\n          type=\"button\"\n          disabled={unavailable}\n          onClick={() => inputRef.current?.click()}\n          className=\"text-foreground focus-visible:ring-ring/50 rounded-sm font-medium underline underline-offset-4 outline-none focus-visible:ring-[3px]\"\n        >\n          browse your computer\n        </button>\n      </p>\n      <p className=\"text-muted-foreground mt-4 text-xs text-pretty\">\n        {acceptLabel} · up to {bytes(maxSize)} each · {maxFiles} files at a time\n      </p>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        multiple\n        accept={accept}\n        tabIndex={-1}\n        aria-hidden=\"true\"\n        className=\"sr-only\"\n        onChange={(e) => {\n          add([...(e.target.files ?? [])])\n          e.target.value = \"\"\n        }}\n      />\n    </div>\n  )\n\n  const content = (\n    <div className=\"grid gap-6\">\n      {unavailable ? (\n        <div role=\"alert\" className=\"border-destructive/30 bg-destructive/5 flex items-start gap-3 rounded-xl border px-4 py-3 text-sm\">\n          <CircleAlert aria-hidden=\"true\" className=\"text-destructive mt-0.5 size-4 shrink-0\" />\n          <div className=\"min-w-0 flex-1\">\n            <p className=\"font-medium\">{errorState?.title ?? \"Uploads are unavailable right now\"}</p>\n            <p className=\"text-muted-foreground mt-0.5 text-xs text-pretty\">{errorState?.description ?? \"Your files were not lost. Try again in a moment.\"}</p>\n          </div>\n          {onRetry ? (\n            <Button variant=\"outline\" size=\"sm\" onClick={onRetry}>\n              <RotateCw aria-hidden=\"true\" />\n              Try again\n            </Button>\n          ) : null}\n        </div>\n      ) : null}\n      {drop}\n\n      {total ? (\n        <div>\n          <div className=\"mb-3 flex flex-wrap items-center gap-x-4 gap-y-2\">\n            <div className=\"min-w-0 flex-1 basis-48\">\n              <h2 className=\"text-sm font-semibold tabular-nums\">\n                {done} of {total} uploaded\n                {failed ? <span className=\"text-destructive font-normal\"> · {failed} failed</span> : null}\n              </h2>\n              <div role=\"progressbar\" aria-label=\"All uploads\" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(overall * 100)} className=\"bg-muted mt-2 h-1.5 overflow-hidden rounded-full\">\n                <div className={cn(\"h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\", done === total ? \"bg-chart-2\" : \"bg-primary\")} style={{ width: `${Math.round(overall * 100)}%` }} />\n              </div>\n            </div>\n            <div className=\"flex gap-2\">\n              {active ? (\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={() => {\n                    queue.current = []\n                    controllers.current.forEach((c) => c.abort())\n                    setItems((l) => l.map((i) => (i.status === \"queued\" ? { ...i, status: \"failed\", error: \"Cancelled.\" } : i)))\n                  }}\n                >\n                  Cancel all\n                </Button>\n              ) : null}\n              {done ? (\n                <Button variant=\"ghost\" size=\"sm\" onClick={() => shown.filter((i) => i.status === \"done\").forEach(remove)}>\n                  Clear completed\n                </Button>\n              ) : null}\n            </div>\n          </div>\n          <ul aria-label=\"Files\" className=\"grid gap-2\">\n            {shown.map((i) => {\n              const tile = tileFor(i)\n              const pct = Math.round((i.progress ?? 0) * 100)\n              const canRetry = i.status === \"failed\" && files.current.has(i.id)\n              return (\n                <li key={i.id} className={cn(\"bg-card flex items-center gap-4 rounded-xl border p-3 pe-2 transition-colors duration-200\", i.status === \"failed\" && \"border-destructive/40\")}>\n                  <span aria-hidden=\"true\" className={cn(\"relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg [&_svg]:size-5\", tile.tone)}>\n                    {i.preview ? <Media media={i.preview} alt=\"\" fill className=\"absolute inset-0\" /> : <span className=\"font-mono text-[10px] font-semibold\">{extOf(i.name)}</span>}\n                  </span>\n                  <div className=\"min-w-0 flex-1\">\n                    <p className=\"truncate text-sm font-medium\">{i.name}</p>\n                    {i.status === \"uploading\" ? (\n                      <>\n                        <div role=\"progressbar\" aria-label={`${i.name} upload`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct} className=\"bg-muted mt-1.5 h-1.5 overflow-hidden rounded-full\">\n                          <div className=\"bg-primary h-full rounded-full transition-[width] duration-150 motion-reduce:transition-none\" style={{ width: `${pct}%` }} />\n                        </div>\n                        <p className=\"text-muted-foreground mt-1 text-xs tabular-nums\">\n                          {pct}% · {bytes(Math.round(i.size * (i.progress ?? 0)))} of {bytes(i.size)}\n                        </p>\n                      </>\n                    ) : (\n                      <p className={cn(\"mt-0.5 flex items-center gap-1.5 text-xs\", i.status === \"failed\" ? \"text-destructive\" : \"text-muted-foreground\")}>\n                        {i.status === \"queued\" ? <Clock aria-hidden=\"true\" className=\"size-3.5\" /> : null}\n                        {i.status === \"processing\" ? <Loader2 aria-hidden=\"true\" className=\"size-3.5 animate-spin motion-reduce:animate-none\" /> : null}\n                        {i.status === \"done\" ? <CircleCheck aria-hidden=\"true\" className=\"text-chart-2 size-3.5\" /> : null}\n                        {i.status === \"failed\" ? <CircleAlert aria-hidden=\"true\" className=\"size-3.5\" /> : null}\n                        <span className=\"tabular-nums\">\n                          {bytes(i.size)} · {i.status === \"queued\" ? \"Waiting\" : i.status === \"processing\" ? \"Finishing up\" : i.status === \"done\" ? \"Uploaded\" : i.error ?? \"Failed\"}\n                        </span>\n                      </p>\n                    )}\n                  </div>\n                  {canRetry ? (\n                    <Button variant=\"outline\" size=\"sm\" onClick={() => retry(i)} aria-label={`Retry ${i.name}`}>\n                      <RotateCw aria-hidden=\"true\" />\n                      Retry\n                    </Button>\n                  ) : null}\n                  <Button variant=\"ghost\" size=\"icon-sm\" onClick={() => remove(i)} aria-label={i.status === \"uploading\" || i.status === \"queued\" ? `Cancel ${i.name}` : `Remove ${i.name}`}>\n                    <X aria-hidden=\"true\" />\n                  </Button>\n                </li>\n              )\n            })}\n          </ul>\n        </div>\n      ) : null}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"upload-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-2xl px-4 py-8 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-6\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em]\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 text-sm text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" ? \"loading\" : \"ready\"} label=\"Loading uploads\" loading={<UploadSkeleton />}>\n        {content}\n      </StateView>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Upload1, type Upload1Props, type Upload1Item, type Upload1Status }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/upload-1/upload-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "upload",
      "files",
      "drag and drop",
      "dropzone",
      "progress",
      "retry",
      "validation",
      "attachments",
      "images",
      "multi-file"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "A multi-file upload page. <Upload1 accept=\"image/*,.pdf,.csv\" acceptLabel=\"PNG, JPG, PDF or CSV\" maxSize={10_000_000} maxFiles={10} concurrency={2} onUpload={async (file, report, signal) => { /* send it; call report(0..1); throw to fail */ }} onItemsChange />. Each file is one of queued, uploading, processing, done or failed; rejected files (wrong type, too large, over the limit) are listed as failed with the reason. Without onUpload it simulates uploading.",
      "whenToUse": [
        "Uploading several files to a project, library or ticket",
        "Anywhere progress per file, retry and cancel matter"
      ],
      "whenNotToUse": [
        "Picking one file in a form (use file-dropzone)",
        "Attaching a file to a chat message (use chat-attachment)",
        "Avatar upload (use file-dropzone with a preview)"
      ],
      "composesWith": [
        "app-shell-1",
        "billing-1",
        "file-dropzone"
      ],
      "a11y": [
        {
          "keys": "Tab to Browse",
          "action": "Opens the system file picker with Enter or Space; dropping is an addition, never the only way"
        },
        {
          "keys": "Each file",
          "action": "Has a progress bar with a name and value; completions and failures are announced politely, not every percent"
        },
        {
          "keys": "Failures",
          "action": "State the reason in words and offer Retry when the file is still available"
        }
      ],
      "customization": [
        "accept, acceptLabel, maxSize, maxFiles, concurrency",
        "onUpload gets an AbortSignal: pass it to fetch so Cancel really stops the request; presigned S3 or R2 PUTs report progress with XMLHttpRequest",
        "defaultItems shows earlier uploads; they are display only (there is no file to retry)"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "upload-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton of the drop area and the list under the real heading."
      },
      {
        "id": "empty",
        "title": "Nothing yet",
        "example": "upload-1-empty",
        "trigger": "state=\"empty\" (or defaultItems={[]})",
        "note": "Just the drop area, which is the empty state: it says where to drop and what is accepted."
      },
      {
        "id": "error",
        "title": "Uploads unavailable",
        "example": "upload-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry) and a drop area that is switched off, so nothing is dropped into a broken page."
      }
    ],
    "examples": [
      "upload-1-demo",
      "upload-1-empty",
      "upload-1-loading",
      "upload-1-error"
    ],
    "url": "https://ui.ballmac.com/components/upload-1"
  },
  "categories": [
    "blocks",
    "upload"
  ],
  "type": "registry:block"
}