{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "File Upload",
  "description": "A file picker and dropzone with validation, a visible queue, and optional progress, cancel, and retry handling.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/file-upload/file-upload.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, File, RefreshCw, Trash2, Upload, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type FileUploadStatus = \"queued\" | \"uploading\" | \"complete\" | \"error\"\n\nexport type FileUploadEntry<TResult = unknown> = {\n  id: string\n  file: File\n  status: FileUploadStatus\n  progress: number\n  error?: string\n  result?: TResult\n}\n\nexport type FileUploadRejectionCode = \"type\" | \"size\" | \"duplicate\" | \"count\"\n\nexport type FileUploadRejection = {\n  file: File\n  code: FileUploadRejectionCode\n  message: string\n}\n\nexport type FileUploadAdapter<TResult = unknown> = (\n  file: File,\n  options: {\n    signal: AbortSignal\n    onProgress: (progress: number) => void\n  }\n) => Promise<TResult>\n\nexport type FileUploadProps<TResult = unknown> = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\"\n> & {\n  accept?: string\n  multiple?: boolean\n  maxFiles?: number\n  maxSize?: number\n  disabled?: boolean\n  name?: string\n  title?: string\n  description?: React.ReactNode\n  browseLabel?: string\n  dropLabel?: string\n  uploadFile?: FileUploadAdapter<TResult>\n  autoUpload?: boolean\n  value?: FileUploadEntry<TResult>[]\n  defaultValue?: FileUploadEntry<TResult>[]\n  onFilesAccepted?: (files: File[]) => void\n  onFilesRejected?: (rejections: FileUploadRejection[]) => void\n  onFilesChange?: (files: FileUploadEntry<TResult>[]) => void\n  onValueChange?: (files: FileUploadEntry<TResult>[]) => void\n  onUploadComplete?: (entry: FileUploadEntry<TResult>, result: TResult) => void\n}\n\nconst DEFAULT_MAX_SIZE = 10 * 1024 * 1024\n\nfunction formatBytes(bytes: number) {\n  if (bytes === 0) return \"0 B\"\n\n  const units = [\"B\", \"KB\", \"MB\", \"GB\"]\n  const index = Math.min(\n    Math.floor(Math.log(bytes) / Math.log(1024)),\n    units.length - 1\n  )\n\n  return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`\n}\n\nfunction matchesAccept(file: File, accept?: string) {\n  if (!accept) return true\n\n  return accept.split(\",\").some((rawToken) => {\n    const token = rawToken.trim().toLowerCase()\n    const fileName = file.name.toLowerCase()\n    const fileType = file.type.toLowerCase()\n\n    if (!token) return false\n    if (token.startsWith(\".\")) return fileName.endsWith(token)\n    if (token.endsWith(\"/*\")) return fileType.startsWith(token.slice(0, -1))\n    return fileType === token\n  })\n}\n\nfunction fileKey(file: File) {\n  return `${file.name}:${file.size}:${file.lastModified}`\n}\n\nfunction createId(file: File) {\n  const suffix =\n    typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n      ? crypto.randomUUID()\n      : Math.random().toString(36).slice(2)\n  return `${fileKey(file)}:${suffix}`\n}\n\nfunction clampProgress(progress: number) {\n  return Math.min(100, Math.max(0, Math.round(progress)))\n}\n\nfunction FileUploadInner<TResult = unknown>(\n  {\n    accept,\n    multiple = true,\n    maxFiles = 5,\n    maxSize = DEFAULT_MAX_SIZE,\n    disabled = false,\n    name,\n    title = \"Drop files here\",\n    description,\n    browseLabel = \"Choose files\",\n    dropLabel = \"Let go to add them\",\n    uploadFile,\n    autoUpload = true,\n    value,\n    defaultValue = [],\n    onFilesAccepted,\n    onFilesRejected,\n    onFilesChange,\n    onValueChange,\n    onUploadComplete,\n    className,\n    ...rootProps\n  }: FileUploadProps<TResult>,\n  forwardedRef: React.ForwardedRef<HTMLDivElement>\n) {\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const dragDepthRef = React.useRef(0)\n  const controllersRef = React.useRef(new Map<string, AbortController>())\n  const [isDragging, setIsDragging] = React.useState(false)\n  const [uncontrolledFiles, setUncontrolledFiles] =\n    React.useState<FileUploadEntry<TResult>[]>(defaultValue)\n  const [notice, setNotice] = React.useState(\"\")\n  const [rejectionMessage, setRejectionMessage] = React.useState<string | null>(\n    null\n  )\n\n  const files = value ?? uncontrolledFiles\n  const filesRef = React.useRef(files)\n\n  React.useEffect(() => {\n    filesRef.current = files\n  }, [files])\n\n  const updateFiles = React.useCallback(\n    (\n      update:\n        | FileUploadEntry<TResult>[]\n        | ((current: FileUploadEntry<TResult>[]) => FileUploadEntry<TResult>[])\n    ) => {\n      const nextFiles =\n        typeof update === \"function\" ? update(filesRef.current) : update\n      filesRef.current = nextFiles\n      if (value === undefined) setUncontrolledFiles(nextFiles)\n      onValueChange?.(nextFiles)\n      onFilesChange?.(nextFiles)\n    },\n    [onFilesChange, onValueChange, value]\n  )\n\n  React.useEffect(() => {\n    const controllers = controllersRef.current\n    return () => {\n      controllers.forEach((controller) => controller.abort())\n      controllers.clear()\n    }\n  }, [])\n\n  const startUpload = React.useCallback(\n    async (entry: FileUploadEntry<TResult>) => {\n      if (!uploadFile || disabled) return\n\n      controllersRef.current.get(entry.id)?.abort()\n      const controller = new AbortController()\n      controllersRef.current.set(entry.id, controller)\n\n      updateFiles((current) =>\n        current.map((item) =>\n          item.id === entry.id\n            ? { ...item, status: \"uploading\", progress: 0, error: undefined }\n            : item\n        )\n      )\n\n      try {\n        const result = await uploadFile(entry.file, {\n          signal: controller.signal,\n          onProgress: (progress) => {\n            if (controller.signal.aborted) return\n            updateFiles((current) =>\n              current.map((item) =>\n                item.id === entry.id\n                  ? { ...item, progress: clampProgress(progress) }\n                  : item\n              )\n            )\n          },\n        })\n\n        if (controller.signal.aborted) return\n        const completedEntry: FileUploadEntry<TResult> = {\n          ...entry,\n          status: \"complete\",\n          progress: 100,\n          error: undefined,\n          result,\n        }\n        updateFiles((current) =>\n          current.map((item) => (item.id === entry.id ? completedEntry : item))\n        )\n        onUploadComplete?.(completedEntry, result)\n        setNotice(`${entry.file.name} uploaded.`)\n      } catch (error) {\n        if (controller.signal.aborted) return\n        const message =\n          error instanceof Error ? error.message : \"The upload did not finish.\"\n        updateFiles((current) =>\n          current.map((item) =>\n            item.id === entry.id\n              ? { ...item, status: \"error\", error: message }\n              : item\n          )\n        )\n        setNotice(`${entry.file.name} needs another try.`)\n      } finally {\n        if (controllersRef.current.get(entry.id) === controller) {\n          controllersRef.current.delete(entry.id)\n        }\n      }\n    },\n    [disabled, onUploadComplete, updateFiles, uploadFile]\n  )\n\n  const addFiles = React.useCallback(\n    (nextFiles: FileList | File[]) => {\n      if (disabled) return\n\n      const candidates = Array.from(nextFiles)\n      const currentFiles = filesRef.current\n      const existingKeys = new Set(\n        currentFiles.map(({ file }) => fileKey(file))\n      )\n      const accepted: File[] = []\n      const rejections: FileUploadRejection[] = []\n      const remaining = Math.max(0, maxFiles - currentFiles.length)\n\n      candidates.forEach((file) => {\n        if (\n          accepted.length >= remaining ||\n          (!multiple && accepted.length > 0)\n        ) {\n          rejections.push({\n            file,\n            code: \"count\",\n            message: `You can add up to ${multiple ? maxFiles : 1} file${multiple && maxFiles !== 1 ? \"s\" : \"\"}.`,\n          })\n        } else if (!matchesAccept(file, accept)) {\n          rejections.push({\n            file,\n            code: \"type\",\n            message: `${file.name} is not an accepted file type.`,\n          })\n        } else if (file.size > maxSize) {\n          rejections.push({\n            file,\n            code: \"size\",\n            message: `${file.name} is larger than ${formatBytes(maxSize)}.`,\n          })\n        } else if (existingKeys.has(fileKey(file))) {\n          rejections.push({\n            file,\n            code: \"duplicate\",\n            message: `${file.name} is already in the list.`,\n          })\n        } else {\n          accepted.push(file)\n          existingKeys.add(fileKey(file))\n        }\n      })\n\n      const entries = accepted.map<FileUploadEntry<TResult>>((file) => ({\n        id: createId(file),\n        file,\n        status: \"queued\",\n        progress: 0,\n      }))\n\n      if (entries.length > 0) {\n        updateFiles((current) =>\n          multiple ? [...current, ...entries] : entries\n        )\n        onFilesAccepted?.(accepted)\n        setRejectionMessage(null)\n        setNotice(\n          `${entries.length} file${entries.length === 1 ? \"\" : \"s\"} added.`\n        )\n        if (uploadFile && autoUpload) {\n          entries.forEach((entry) => void startUpload(entry))\n        }\n      }\n\n      if (rejections.length > 0) {\n        onFilesRejected?.(rejections)\n        const message =\n          rejections[0]?.message ?? \"One or more files were not added.\"\n        setRejectionMessage(message)\n        setNotice(message)\n      }\n    },\n    [\n      accept,\n      autoUpload,\n      disabled,\n      maxFiles,\n      maxSize,\n      multiple,\n      onFilesAccepted,\n      onFilesRejected,\n      startUpload,\n      updateFiles,\n      uploadFile,\n    ]\n  )\n\n  const removeFile = React.useCallback(\n    (id: string) => {\n      controllersRef.current.get(id)?.abort()\n      controllersRef.current.delete(id)\n      updateFiles((current) => current.filter((item) => item.id !== id))\n    },\n    [updateFiles]\n  )\n\n  const cancelUpload = React.useCallback(\n    (id: string) => {\n      controllersRef.current.get(id)?.abort()\n      controllersRef.current.delete(id)\n      updateFiles((current) =>\n        current.map((item) =>\n          item.id === id\n            ? {\n                ...item,\n                status: \"queued\",\n                progress: 0,\n                error: undefined,\n                result: undefined,\n              }\n            : item\n        )\n      )\n    },\n    [updateFiles]\n  )\n\n  const helpText =\n    description ??\n    `${accept ? accept.split(\",\").join(\", \") : \"Any file type\"} · Up to ${formatBytes(maxSize)}`\n\n  return (\n    <div\n      className={cn(\"w-full space-y-3\", className)}\n      data-slot=\"file-upload\"\n      ref={forwardedRef}\n      {...rootProps}\n    >\n      <div\n        data-slot=\"file-upload-dropzone\"\n        className={cn(\n          \"border-border bg-background relative flex min-h-56 flex-col items-center justify-center overflow-hidden rounded-[var(--radius)] border border-dashed px-6 py-8 text-center transition-[border-color,background-color] duration-200\",\n          \"motion-reduce:transition-none\",\n          isDragging && \"border-primary bg-primary/5\",\n          disabled && \"cursor-not-allowed opacity-55\"\n        )}\n        data-dragging={isDragging || undefined}\n        onDragEnter={(event) => {\n          event.preventDefault()\n          if (disabled) return\n          dragDepthRef.current += 1\n          setIsDragging(true)\n        }}\n        onDragLeave={(event) => {\n          event.preventDefault()\n          dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)\n          if (dragDepthRef.current === 0) setIsDragging(false)\n        }}\n        onDragOver={(event) => event.preventDefault()}\n        onDrop={(event) => {\n          event.preventDefault()\n          dragDepthRef.current = 0\n          setIsDragging(false)\n          if (event.dataTransfer.files.length > 0) {\n            addFiles(event.dataTransfer.files)\n          }\n        }}\n      >\n        <div\n          data-slot=\"file-upload-illustration\"\n          aria-hidden=\"true\"\n          className=\"text-foreground relative mb-5 h-14 w-24\"\n        >\n          <span\n            className={cn(\n              \"border-border bg-background absolute top-2 left-1/2 h-11 w-9 -translate-x-[88%] -rotate-6 rounded-md border transition-transform duration-200\",\n              isDragging && \"-translate-x-[105%] -translate-y-1 -rotate-12\"\n            )}\n          />\n          <span\n            className={cn(\n              \"border-border bg-background absolute top-2 left-1/2 z-10 grid h-11 w-9 -translate-x-1/2 place-items-center rounded-md border transition-transform duration-200\",\n              isDragging && \"-translate-y-2 scale-105\"\n            )}\n          >\n            <File size={17} strokeWidth={1.8} />\n          </span>\n          <span\n            className={cn(\n              \"border-border bg-background absolute top-2 left-1/2 h-11 w-9 -translate-x-[12%] rotate-6 rounded-md border transition-transform duration-200\",\n              isDragging && \"translate-x-[5%] -translate-y-1 rotate-12\"\n            )}\n          />\n        </div>\n\n        <p data-slot=\"file-upload-title\" className=\"font-medium\">\n          {isDragging ? dropLabel : title}\n        </p>\n        <div\n          data-slot=\"file-upload-description\"\n          className=\"text-muted-foreground mt-1 max-w-md text-sm\"\n        >\n          {helpText}\n        </div>\n        {rejectionMessage ? (\n          <p\n            data-slot=\"file-upload-error\"\n            className=\"text-destructive mt-2 max-w-md text-sm\"\n          >\n            {rejectionMessage}\n          </p>\n        ) : null}\n\n        <button\n          data-slot=\"file-upload-browse\"\n          className=\"bg-foreground text-background focus-visible:ring-ring mt-5 inline-flex min-h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none\"\n          disabled={disabled}\n          type=\"button\"\n          onClick={() => inputRef.current?.click()}\n        >\n          <Upload aria-hidden=\"true\" size={16} />\n          {browseLabel}\n        </button>\n        <input\n          data-slot=\"file-upload-input\"\n          ref={inputRef}\n          className=\"hidden\"\n          type=\"file\"\n          name={name}\n          accept={accept}\n          multiple={multiple}\n          disabled={disabled}\n          tabIndex={-1}\n          onChange={(event) => {\n            if (event.currentTarget.files) addFiles(event.currentTarget.files)\n            event.currentTarget.value = \"\"\n          }}\n        />\n      </div>\n\n      {files.length > 0 ? (\n        <ul\n          data-slot=\"file-upload-list\"\n          className=\"border-border bg-background divide-border divide-y rounded-[var(--radius)] border\"\n        >\n          {files.map((entry) => (\n            <li\n              data-slot=\"file-upload-item\"\n              className=\"flex min-w-0 items-center gap-3 p-3\"\n              key={entry.id}\n            >\n              <span className=\"bg-muted text-muted-foreground grid size-10 shrink-0 place-items-center rounded-lg\">\n                <File aria-hidden=\"true\" size={18} />\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex items-center justify-between gap-3\">\n                  <p className=\"truncate text-sm font-medium\">\n                    {entry.file.name}\n                  </p>\n                  <span className=\"text-muted-foreground shrink-0 text-xs\">\n                    {formatBytes(entry.file.size)}\n                  </span>\n                </div>\n                {entry.status === \"uploading\" ? (\n                  <div className=\"mt-2 flex items-center gap-2\">\n                    <div\n                      data-slot=\"file-upload-progress\"\n                      aria-label={`${entry.file.name} upload progress`}\n                      aria-valuemax={100}\n                      aria-valuemin={0}\n                      aria-valuenow={entry.progress}\n                      className=\"bg-muted h-1.5 flex-1 overflow-hidden rounded-full\"\n                      role=\"progressbar\"\n                    >\n                      <span\n                        className=\"bg-primary block h-full rounded-full transition-[width] duration-150 motion-reduce:transition-none\"\n                        style={{ width: `${entry.progress}%` }}\n                      />\n                    </div>\n                    <span className=\"text-muted-foreground w-8 text-right text-xs tabular-nums\">\n                      {entry.progress}%\n                    </span>\n                  </div>\n                ) : (\n                  <p\n                    className={cn(\n                      \"text-muted-foreground mt-0.5 truncate text-xs\",\n                      entry.status === \"error\" && \"text-destructive\"\n                    )}\n                  >\n                    {entry.status === \"complete\"\n                      ? \"Uploaded\"\n                      : entry.status === \"error\"\n                        ? entry.error\n                        : uploadFile\n                          ? \"Ready to upload\"\n                          : \"Ready\"}\n                  </p>\n                )}\n              </div>\n\n              <div\n                data-slot=\"file-upload-actions\"\n                className=\"flex shrink-0 items-center\"\n              >\n                {entry.status === \"complete\" ? (\n                  <span\n                    aria-label=\"Upload complete\"\n                    className=\"text-primary grid size-11 place-items-center\"\n                    role=\"img\"\n                  >\n                    <Check aria-hidden=\"true\" size={18} />\n                  </span>\n                ) : null}\n                {uploadFile && entry.status === \"queued\" ? (\n                  <button\n                    aria-label={`Upload ${entry.file.name}`}\n                    className=\"hover:bg-muted focus-visible:ring-ring grid size-11 place-items-center rounded-full outline-none focus-visible:ring-2\"\n                    type=\"button\"\n                    onClick={() => void startUpload(entry)}\n                  >\n                    <Upload aria-hidden=\"true\" size={17} />\n                  </button>\n                ) : null}\n                {entry.status === \"uploading\" ? (\n                  <button\n                    aria-label={`Cancel ${entry.file.name}`}\n                    className=\"hover:bg-muted focus-visible:ring-ring grid size-11 place-items-center rounded-full outline-none focus-visible:ring-2\"\n                    type=\"button\"\n                    onClick={() => cancelUpload(entry.id)}\n                  >\n                    <X aria-hidden=\"true\" size={17} />\n                  </button>\n                ) : null}\n                {entry.status === \"error\" ? (\n                  <button\n                    aria-label={`Retry ${entry.file.name}`}\n                    className=\"hover:bg-muted focus-visible:ring-ring grid size-11 place-items-center rounded-full outline-none focus-visible:ring-2\"\n                    type=\"button\"\n                    onClick={() => void startUpload(entry)}\n                  >\n                    <RefreshCw aria-hidden=\"true\" size={16} />\n                  </button>\n                ) : null}\n                {entry.status !== \"uploading\" ? (\n                  <button\n                    aria-label={`Remove ${entry.file.name}`}\n                    className=\"hover:bg-muted focus-visible:ring-ring grid size-11 place-items-center rounded-full outline-none focus-visible:ring-2\"\n                    type=\"button\"\n                    onClick={() => removeFile(entry.id)}\n                  >\n                    <Trash2 aria-hidden=\"true\" size={16} />\n                  </button>\n                ) : null}\n              </div>\n            </li>\n          ))}\n        </ul>\n      ) : null}\n\n      <p\n        data-slot=\"file-upload-status\"\n        className=\"sr-only\"\n        aria-live=\"polite\"\n        role=\"status\"\n      >\n        {notice}\n      </p>\n    </div>\n  )\n}\n\nexport const FileUpload = React.forwardRef(FileUploadInner) as <\n  TResult = unknown,\n>(\n  props: FileUploadProps<TResult> & React.RefAttributes<HTMLDivElement>\n) => React.ReactElement\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "file",
    "upload",
    "dropzone",
    "form"
  ],
  "type": "registry:ui"
}