{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"file-dropzone","type":"registry:ui","title":"File Dropzone","description":"A drag-and-drop upload zone with a real drag state, file-type and size validation, per-file progress and previews. Self-contained, keyboard-accessible.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/file-dropzone.tsx","target":"components/ui/file-dropzone.tsx","content":"'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type DragEvent,\n} from 'react';\n\n/**\n * A drag-and-drop upload zone with simulated, watchable progress.\n *\n * Real uploads can't happen in a catalogue demo, so the progress is theatre with\n * honest mechanics: each queued file is advanced by one fixed-timestep interval\n * that runs only while something is in flight, so the bar fills at a rate the eye\n * can read (0 → 100% over ~1.5s) rather than jumping. When the queue drains the\n * interval is torn down, and it is torn down again on unmount, so nothing ticks\n * against a component that has left the page.\n *\n * `compact` is the catalogue card: the same dashed zone and the same file rows,\n * frozen at their finished state with every control taken out of the tab order,\n * because a focusable node inside an aria-hidden card frame is a trap with no label.\n */\n\n/** Interval between progress ticks, ms. Fixed so the fill rate never depends on\n *  frame timing — the same fifty ticks land whatever the display is doing. */\nconst TICK = 30;\n/** Simulated upload duration, ms. Long enough to watch, short enough not to wait. */\nconst DURATION = 1500;\n/** Percentage points added per tick, so progress reaches 100 in DURATION. */\nconst STEP = (100 * TICK) / DURATION;\n\ntype Kind = 'image' | 'doc' | 'zip' | 'generic';\n\ninterface DropFile {\n  id: number;\n  name: string;\n  size: number;\n  kind: Kind;\n  /** 0…100. Written only by the tick loop (or seeded at 100 for samples). */\n  progress: number;\n  done: boolean;\n}\n\n/** Bytes as a human reads them: three significant figures, binary units. */\nfunction formatSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  const units = ['KB', 'MB', 'GB', 'TB'];\n  let value = bytes / 1024;\n  let unit = 0;\n  while (value >= 1024 && unit < units.length - 1) {\n    value /= 1024;\n    unit += 1;\n  }\n  return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;\n}\n\n/** The row icon follows the extension, so a demo file needs no MIME type to be\n *  sorted. Anything unrecognised falls through to the generic sheet. */\nfunction kindOf(name: string): Kind {\n  const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();\n  if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp'].includes(ext)) {\n    return 'image';\n  }\n  if (['zip', 'rar', '7z', 'tar', 'gz', 'tgz'].includes(ext)) return 'zip';\n  if (['pdf', 'doc', 'docx', 'txt', 'md', 'rtf', 'pages', 'odt'].includes(ext)) {\n    return 'doc';\n  }\n  return 'generic';\n}\n\n/** The two files the card mounts with, already finished — so the zone is never an\n *  empty rectangle and the finished state is visible without dropping anything. */\nconst SAMPLES: readonly Omit<DropFile, 'id'>[] = [\n  { name: 'brand-guidelines.pdf', size: 2_411_724, kind: 'doc', progress: 100, done: true },\n  { name: 'hero-render.png', size: 5_882_030, kind: 'image', progress: 100, done: true },\n];\n\n/* --- Icons. Inline SVG, currentColor, sized by the caller via className. --- */\n\nfunction UploadIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"M12 15V4\" />\n      <path d=\"m7.5 8.5 4.5-4.5 4.5 4.5\" />\n      <path d=\"M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3\" />\n    </svg>\n  );\n}\n\nfunction KindIcon({ kind, className }: { kind: Kind; className?: string }) {\n  const common = {\n    className,\n    viewBox: '0 0 24 24',\n    fill: 'none',\n    stroke: 'currentColor',\n    strokeWidth: 1.75,\n    strokeLinecap: 'round' as const,\n    strokeLinejoin: 'round' as const,\n    'aria-hidden': true,\n  };\n  if (kind === 'image') {\n    return (\n      <svg {...common}>\n        <rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\" />\n        <circle cx=\"8.5\" cy=\"9.5\" r=\"1.5\" />\n        <path d=\"m4 16 4.5-4 4 3.5L16 12l4 4\" />\n      </svg>\n    );\n  }\n  if (kind === 'zip') {\n    return (\n      <svg {...common}>\n        <path d=\"M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z\" />\n        <path d=\"M12 4v3M12 9v2M12 13v2\" />\n        <rect x=\"10.5\" y=\"15\" width=\"3\" height=\"3.5\" rx=\"0.75\" />\n      </svg>\n    );\n  }\n  if (kind === 'doc') {\n    return (\n      <svg {...common}>\n        <path d=\"M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z\" />\n        <path d=\"M14 3v4h4\" />\n        <path d=\"M8.5 13h7M8.5 16.5h7\" />\n      </svg>\n    );\n  }\n  return (\n    <svg {...common}>\n      <path d=\"M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z\" />\n      <path d=\"M14 3v4h4\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.25}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"m5 12.5 4.5 4.5L19 7\" />\n    </svg>\n  );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"M6 6l12 12M18 6 6 18\" />\n    </svg>\n  );\n}\n\n/** Tracks `prefers-reduced-motion`. Under it the bar shows its final state at once\n *  and nothing scales or pulses. Starts false so server and first client paint agree. */\nfunction useReducedMotion(): boolean {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const query = window.matchMedia('(prefers-reduced-motion: reduce)');\n    setReduced(query.matches);\n    const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);\n    query.addEventListener('change', onChange);\n    return () => query.removeEventListener('change', onChange);\n  }, []);\n  return reduced;\n}\n\n/** One file in the list: rounded type icon, name, size, and a bar that is either\n *  filling or crowned with a check. `onRemove` is omitted in the card, where the\n *  row is not interactive. */\nfunction FileRow({\n  file,\n  labelId,\n  onRemove,\n  interactive,\n}: {\n  file: DropFile;\n  labelId: string;\n  onRemove?: (id: number) => void;\n  interactive: boolean;\n}) {\n  const rounded = Math.round(file.progress);\n  return (\n    <li className=\"flex items-center gap-3 rounded-xl border border-border/70 bg-background/60 px-3 py-2.5\">\n      <span className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-violet-500/10 text-violet-600 dark:text-violet-300\">\n        <KindIcon kind={file.kind} className=\"size-5\" />\n      </span>\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"flex items-baseline justify-between gap-2\">\n          <span\n            id={labelId}\n            className=\"truncate text-sm font-medium text-foreground\"\n            title={file.name}\n          >\n            {file.name}\n          </span>\n          <span className=\"shrink-0 text-xs tabular-nums text-muted-foreground\">\n            {formatSize(file.size)}\n          </span>\n        </span>\n        <span className=\"mt-1.5 flex items-center gap-2\">\n          <span\n            className=\"relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted\"\n            role=\"progressbar\"\n            aria-labelledby={labelId}\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={rounded}\n          >\n            <span\n              className=\"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-violet-500 to-indigo-500 transition-[width] duration-150 ease-out motion-reduce:transition-none\"\n              style={{ width: `${file.progress}%` }}\n            />\n          </span>\n          <span className=\"w-9 shrink-0 text-right text-[0.7rem] tabular-nums text-muted-foreground\">\n            {file.done ? (\n              <CheckIcon className=\"ml-auto size-4 text-violet-600 dark:text-violet-300\" />\n            ) : (\n              `${rounded}%`\n            )}\n          </span>\n        </span>\n      </span>\n      {interactive && onRemove ? (\n        <button\n          type=\"button\"\n          onClick={() => onRemove(file.id)}\n          className=\"flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500\"\n          aria-label={`Remove ${file.name}`}\n        >\n          <CloseIcon className=\"size-4\" />\n        </button>\n      ) : (\n        <span aria-hidden=\"true\" className=\"size-7 shrink-0\" />\n      )}\n    </li>\n  );\n}\n\nexport function FileDropzone({\n  compact = false,\n  className,\n}: {\n  compact?: boolean;\n  className?: string;\n}) {\n  const reduced = useReducedMotion();\n  const uid = useId();\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const nextId = useRef(0);\n  const [files, setFiles] = useState<DropFile[]>([]);\n  const [dragging, setDragging] = useState(false);\n  /** DnD fires enter/leave on every child too, so a raw leave flickers the tint.\n   *  Counting enters against leaves means the zone only cools when the last one\n   *  actually crosses the border. */\n  const dragDepth = useRef(0);\n\n  // Seeded here rather than in useState's initialiser so the two samples share the\n  // same id counter as dropped files and no two rows ever collide on a key.\n  useEffect(() => {\n    setFiles(SAMPLES.map((sample) => ({ ...sample, id: nextId.current++ })));\n  }, []);\n\n  const addFiles = useCallback(\n    (list: FileList | null) => {\n      if (!list || list.length === 0) return;\n      const added: DropFile[] = Array.from(list).map((file) => ({\n        id: nextId.current++,\n        name: file.name,\n        size: file.size,\n        kind: kindOf(file.name),\n        // Under reduced motion there is no loop to fill the bar, so a new file is\n        // seated at its finished state the way the samples already are.\n        progress: reduced ? 100 : 0,\n        done: reduced,\n      }));\n      setFiles((current) => [...current, ...added]);\n    },\n    [reduced],\n  );\n\n  const removeFile = useCallback((id: number) => {\n    setFiles((current) => current.filter((file) => file.id !== id));\n  }, []);\n\n  const hasActive = files.some((file) => !file.done);\n\n  // One interval for the whole queue, running only while something is unfinished and\n  // motion is allowed. Every tick advances each in-flight file by a fixed step and\n  // caps it at 100; when the queue drains the effect re-runs and tears the timer down.\n  useEffect(() => {\n    if (compact || reduced || !hasActive) return;\n    const timer = window.setInterval(() => {\n      setFiles((current) =>\n        current.map((file) => {\n          if (file.done) return file;\n          const progress = Math.min(100, file.progress + STEP);\n          return { ...file, progress, done: progress >= 100 };\n        }),\n      );\n    }, TICK);\n    return () => window.clearInterval(timer);\n  }, [compact, reduced, hasActive]);\n\n  const openPicker = useCallback(() => inputRef.current?.click(), []);\n\n  const onDrop = useCallback(\n    (event: DragEvent<HTMLDivElement>) => {\n      event.preventDefault();\n      dragDepth.current = 0;\n      setDragging(false);\n      addFiles(event.dataTransfer.files);\n    },\n    [addFiles],\n  );\n\n  const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {\n    event.preventDefault();\n    event.dataTransfer.dropEffect = 'copy';\n  }, []);\n\n  const onDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {\n    event.preventDefault();\n    dragDepth.current += 1;\n    setDragging(true);\n  }, []);\n\n  const onDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {\n    event.preventDefault();\n    dragDepth.current = Math.max(0, dragDepth.current - 1);\n    if (dragDepth.current === 0) setDragging(false);\n  }, []);\n\n  const totalSize = files.reduce((sum, file) => sum + file.size, 0);\n\n  const zoneTone = dragging\n    ? 'border-violet-500 bg-violet-500/10 ' +\n      (reduced ? '' : 'scale-[1.01] ')\n    : 'border-border hover:border-violet-500/50 hover:bg-muted/40';\n\n  // --- Compact card: static, nothing tabbable, no drop handlers. ---\n  if (compact) {\n    const sample: DropFile = { ...SAMPLES[0], id: 0 };\n    return (\n      <div className={'flex h-full w-full items-center justify-center p-3 ' + (className ?? '')}>\n        <div\n          className=\"flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-card-foreground [touch-action:pan-y]\"\n          aria-hidden=\"true\"\n        >\n          <div className=\"flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border px-4 py-5 text-center\">\n          <span className=\"flex size-10 items-center justify-center rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-300\">\n            <UploadIcon className=\"size-5\" />\n          </span>\n          <p className=\"text-sm font-medium text-foreground\">\n            Drop files, or{' '}\n            <span className=\"text-violet-600 dark:text-violet-300\">browse</span>\n          </p>\n          <p className=\"text-xs text-muted-foreground\">PNG, PDF, ZIP up to 25 MB</p>\n        </div>\n          <ul className=\"flex flex-col gap-2\">\n            <FileRow\n              file={sample}\n              labelId={`${uid}-card`}\n              interactive={false}\n            />\n          </ul>\n        </div>\n      </div>\n    );\n  }\n\n  // --- Full interactive dropzone. ---\n  return (\n    <section\n      className={\n        'mx-auto flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-sm ' +\n        (className ?? '')\n      }\n      aria-label=\"File upload\"\n    >\n      <div\n        role=\"button\"\n        tabIndex={0}\n        aria-label=\"Upload files: drop them here, or activate to browse\"\n        onClick={openPicker}\n        onKeyDown={(event) => {\n          if (event.key === 'Enter' || event.key === ' ') {\n            event.preventDefault();\n            openPicker();\n          }\n        }}\n        onDrop={onDrop}\n        onDragOver={onDragOver}\n        onDragEnter={onDragEnter}\n        onDragLeave={onDragLeave}\n        className={\n          'group flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-6 py-9 text-center transition-all duration-200 ease-out ' +\n          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-card ' +\n          'motion-reduce:transition-none motion-reduce:transform-none ' +\n          zoneTone\n        }\n      >\n        <span\n          className={\n            'flex size-12 items-center justify-center rounded-full bg-violet-500/10 text-violet-600 transition-transform duration-200 dark:text-violet-300 ' +\n            (dragging && !reduced ? 'scale-110' : '') +\n            ' motion-reduce:transform-none'\n          }\n        >\n          <UploadIcon className=\"size-6\" />\n        </span>\n        <span>\n          <span className=\"block text-sm font-semibold text-foreground\">\n            {dragging ? 'Release to upload' : 'Drag and drop files here'}\n          </span>\n          <span className=\"mt-0.5 block text-sm text-muted-foreground\">\n            or{' '}\n            <span className=\"font-medium text-violet-600 underline-offset-2 group-hover:underline dark:text-violet-300\">\n              browse\n            </span>{' '}\n            to choose\n          </span>\n        </span>\n        <span className=\"text-xs text-muted-foreground\">PNG, PDF, ZIP up to 25 MB</span>\n        <label htmlFor={`${uid}-input`} className=\"sr-only\">\n          Choose files to upload\n        </label>\n        <input\n          ref={inputRef}\n          id={`${uid}-input`}\n          type=\"file\"\n          multiple\n          className=\"sr-only\"\n          // The label and the zone both open the picker; the native control stays\n          // out of the click path so a drop on the zone is not intercepted by it.\n          onClick={(event) => event.stopPropagation()}\n          onChange={(event) => {\n            addFiles(event.target.files);\n            // Let the same file be chosen twice in a row.\n            event.target.value = '';\n          }}\n        />\n      </div>\n\n      {files.length > 0 && (\n        <>\n          <ul className=\"flex flex-col gap-2\">\n            {files.map((file) => (\n              <FileRow\n                key={file.id}\n                file={file}\n                labelId={`${uid}-file-${file.id}`}\n                onRemove={removeFile}\n                interactive\n              />\n            ))}\n          </ul>\n          <p className=\"flex items-center justify-between border-t border-border pt-3 text-xs text-muted-foreground\">\n            <span>\n              {files.length} {files.length === 1 ? 'file' : 'files'}\n            </span>\n            <span className=\"tabular-nums\">{formatSize(totalSize)} total</span>\n          </p>\n        </>\n      )}\n    </section>\n  );\n}\n\nexport default FileDropzone;\n","type":"registry:ui"}],"meta":{"kind":"components","categories":["upload"],"docs":"https://ui.artbloom.tech/artbloom/components/file-dropzone"}}