{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "File Upload",
  "description": "An accessible file picker with drag and drop, controlled selection, validation, and removable file rows.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/rivetui/components/file-upload/file-upload.tsx",
      "content": "\"use client\";\n\nimport { FileIcon, Upload, X } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype FileUploadErrorCode = \"file-too-large\" | \"file-type-not-accepted\" | \"too-many-files\";\n\ntype FileUploadRejection = {\n  file: File;\n  code: FileUploadErrorCode;\n  message: string;\n};\n\ntype FileUploadContextValue = {\n  accept?: string;\n  addFiles: (files: File[]) => void;\n  disabled: boolean;\n  error: string | null;\n  errorId: string;\n  files: File[];\n  inputId: string;\n  isDragging: boolean;\n  maxFiles: number;\n  maxSize?: number;\n  multiple: boolean;\n  open: () => void;\n  removeFile: (file: File) => void;\n  setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;\n};\n\nconst FileUploadContext = React.createContext<FileUploadContextValue | null>(null);\n\nfunction useFileUpload() {\n  const context = React.useContext(FileUploadContext);\n\n  if (!context) {\n    throw new Error(\"useFileUpload must be used within a FileUpload.\");\n  }\n\n  return context;\n}\n\ntype FileUploadProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  accept?: string;\n  defaultValue?: File[];\n  disabled?: boolean;\n  maxFiles?: number;\n  maxSize?: number;\n  multiple?: boolean;\n  name?: string;\n  onFileReject?: (rejection: FileUploadRejection) => void;\n  onValueChange?: (files: File[]) => void;\n  required?: boolean;\n  value?: File[];\n};\n\nfunction FileUpload({\n  accept,\n  children,\n  className,\n  defaultValue = [],\n  disabled = false,\n  maxFiles: maxFilesProp,\n  maxSize,\n  multiple = false,\n  name,\n  onFileReject,\n  onValueChange,\n  required,\n  value,\n  ...props\n}: FileUploadProps) {\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const inputId = React.useId();\n  const errorId = React.useId();\n  const [uncontrolledFiles, setUncontrolledFiles] = React.useState(defaultValue);\n  const [error, setError] = React.useState<string | null>(null);\n  const [isDragging, setIsDragging] = React.useState(false);\n  const files = value ?? uncontrolledFiles;\n  const maxFiles = multiple ? Math.max(1, maxFilesProp ?? Number.POSITIVE_INFINITY) : 1;\n\n  const updateFiles = React.useCallback(\n    (nextFiles: File[]) => {\n      if (value === undefined) {\n        setUncontrolledFiles(nextFiles);\n      }\n\n      onValueChange?.(nextFiles);\n    },\n    [onValueChange, value],\n  );\n\n  const addFiles = React.useCallback(\n    (incomingFiles: File[]) => {\n      if (disabled || incomingFiles.length === 0) {\n        return;\n      }\n\n      const nextFiles = multiple ? [...files] : [];\n      const knownFiles = new Set(nextFiles.map(getFileKey));\n      const rejections: FileUploadRejection[] = [];\n      let didAddFile = false;\n\n      for (const file of incomingFiles) {\n        const rejection = getFileRejection(file, accept, maxSize);\n\n        if (rejection) {\n          rejections.push(rejection);\n          continue;\n        }\n\n        if (knownFiles.has(getFileKey(file))) {\n          continue;\n        }\n\n        if (nextFiles.length >= maxFiles) {\n          rejections.push({\n            file,\n            code: \"too-many-files\",\n            message: `Only ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"} can be selected.`,\n          });\n          continue;\n        }\n\n        nextFiles.push(file);\n        knownFiles.add(getFileKey(file));\n        didAddFile = true;\n      }\n\n      setError(rejections[0]?.message ?? null);\n      rejections.forEach((rejection) => {\n        onFileReject?.(rejection);\n      });\n\n      if (didAddFile) {\n        updateFiles(nextFiles);\n      }\n\n      syncInputFiles(inputRef.current, didAddFile ? nextFiles : files);\n    },\n    [accept, disabled, files, maxFiles, maxSize, multiple, onFileReject, updateFiles],\n  );\n\n  const removeFile = React.useCallback(\n    (file: File) => {\n      if (disabled) {\n        return;\n      }\n\n      setError(null);\n      updateFiles(files.filter((candidate) => getFileKey(candidate) !== getFileKey(file)));\n    },\n    [disabled, files, updateFiles],\n  );\n\n  const open = React.useCallback(() => {\n    if (!disabled) {\n      inputRef.current?.click();\n    }\n  }, [disabled]);\n\n  React.useEffect(() => {\n    syncInputFiles(inputRef.current, files);\n  }, [files]);\n\n  const contextValue: FileUploadContextValue = {\n    accept,\n    addFiles,\n    disabled,\n    error,\n    errorId,\n    files,\n    inputId,\n    isDragging,\n    maxFiles,\n    maxSize,\n    multiple,\n    open,\n    removeFile,\n    setIsDragging,\n  };\n\n  return (\n    <FileUploadContext.Provider value={contextValue}>\n      <div\n        data-slot=\"file-upload\"\n        data-disabled={disabled ? \"\" : undefined}\n        className={cn(\"w-full\", className)}\n        {...props}\n      >\n        <input\n          ref={inputRef}\n          id={inputId}\n          type=\"file\"\n          data-slot=\"file-upload-input\"\n          className=\"peer sr-only\"\n          accept={accept}\n          aria-describedby={error ? errorId : undefined}\n          disabled={disabled}\n          multiple={multiple}\n          name={name}\n          required={required}\n          onChange={(event) => addFiles(Array.from(event.currentTarget.files ?? []))}\n        />\n        {children}\n      </div>\n    </FileUploadContext.Provider>\n  );\n}\n\ntype FileUploadDropzoneSize = \"sm\" | \"md\";\n\ntype FileUploadDropzoneProps = React.ComponentProps<\"label\"> & {\n  size?: FileUploadDropzoneSize;\n};\n\nfunction FileUploadDropzone({\n  children,\n  className,\n  htmlFor,\n  onDragEnter,\n  onDragLeave,\n  onDragOver,\n  onDrop,\n  size = \"md\",\n  ...props\n}: FileUploadDropzoneProps) {\n  const {\n    accept,\n    addFiles,\n    disabled,\n    error,\n    errorId,\n    files,\n    inputId,\n    isDragging,\n    maxFiles,\n    maxSize,\n    multiple,\n    setIsDragging,\n  } = useFileUpload();\n  const constraints = [\n    accept ? formatAccept(accept) : \"Any format\",\n    Number.isFinite(maxFiles) ? `${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}` : null,\n    maxSize ? `Up to ${formatBytes(maxSize)}` : null,\n  ].filter(Boolean);\n  const compactLabel = isDragging\n    ? \"Release to add files\"\n    : files.length > 0\n      ? multiple\n        ? `${files.length} ${files.length === 1 ? \"file\" : \"files\"} selected`\n        : files[0]?.name\n      : multiple\n        ? \"Choose files\"\n        : \"Choose file\";\n\n  return (\n    <label\n      htmlFor={htmlFor ?? inputId}\n      data-slot=\"file-upload-dropzone\"\n      data-dragging={isDragging ? \"\" : undefined}\n      data-size={size}\n      aria-describedby={error ? errorId : undefined}\n      aria-disabled={disabled || undefined}\n      className={cn(\n        \"group flex w-full cursor-pointer border border-dashed border-border bg-muted/10 font-sans text-foreground outline-none transition-[background-color,border-color,box-shadow,color] duration-100 hover:border-foreground/40 hover:bg-muted/25 peer-focus-visible:border-ring peer-focus-visible:ring-2 peer-focus-visible:ring-ring/40 data-[dragging]:border-foreground data-[dragging]:border-solid data-[dragging]:bg-muted/50 aria-disabled:pointer-events-none aria-disabled:bg-muted/40 aria-disabled:opacity-50 motion-reduce:transition-none\",\n        size === \"md\"\n          ? \"min-h-40 flex-col items-center justify-center p-6 text-center\"\n          : \"h-10 min-w-0 items-center gap-2 overflow-hidden px-3 text-left\",\n        className,\n      )}\n      onDragEnter={(event) => {\n        onDragEnter?.(event);\n        if (!event.defaultPrevented && !disabled && hasFiles(event.dataTransfer)) {\n          event.preventDefault();\n          setIsDragging(true);\n        }\n      }}\n      onDragOver={(event) => {\n        onDragOver?.(event);\n        if (!event.defaultPrevented && !disabled && hasFiles(event.dataTransfer)) {\n          event.preventDefault();\n          event.dataTransfer.dropEffect = \"copy\";\n        }\n      }}\n      onDragLeave={(event) => {\n        onDragLeave?.(event);\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n          setIsDragging(false);\n        }\n      }}\n      onDrop={(event) => {\n        onDrop?.(event);\n        if (!event.defaultPrevented && !disabled && hasFiles(event.dataTransfer)) {\n          event.preventDefault();\n          setIsDragging(false);\n          addFiles(Array.from(event.dataTransfer.files));\n        }\n      }}\n      {...props}\n    >\n      {children ??\n        (size === \"sm\" ? (\n          <>\n            <Upload\n              className=\"size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground group-data-[dragging]:text-foreground\"\n              aria-hidden=\"true\"\n            />\n            <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">{compactLabel}</span>\n            <span className=\"min-w-0 max-w-1/2 truncate font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground\">\n              {constraints.join(\" · \")}\n            </span>\n          </>\n        ) : (\n          <>\n            <span className=\"mb-4 flex size-10 items-center justify-center border border-border bg-background text-muted-foreground transition-colors group-hover:text-foreground group-data-[dragging]:border-foreground group-data-[dragging]:text-foreground\">\n              <Upload className=\"size-4\" aria-hidden=\"true\" />\n            </span>\n            <span className=\"text-sm font-semibold\">\n              {isDragging ? \"Release to add files\" : \"Drop files here or click to browse\"}\n            </span>\n            <span className=\"mt-1 text-sm text-muted-foreground\">\n              {multiple\n                ? \"Select one or more files from your device.\"\n                : \"Select a file from your device.\"}\n            </span>\n            <span className=\"mt-4 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-muted-foreground\">\n              {constraints.join(\" · \")}\n            </span>\n          </>\n        ))}\n    </label>\n  );\n}\n\ntype FileUploadListProps = Omit<React.ComponentProps<\"ul\">, \"children\"> & {\n  children?: React.ReactNode | ((file: File, index: number) => React.ReactNode);\n};\n\nfunction FileUploadList({ children, className, ...props }: FileUploadListProps) {\n  const { files } = useFileUpload();\n\n  if (files.length === 0 && !children) {\n    return null;\n  }\n\n  return (\n    <ul\n      data-slot=\"file-upload-list\"\n      className={cn(\"mt-3 border border-border\", className)}\n      {...props}\n    >\n      {typeof children === \"function\"\n        ? files.map((file, index) => children(file, index))\n        : (children ?? files.map((file) => <FileUploadItem key={getFileKey(file)} file={file} />))}\n    </ul>\n  );\n}\n\ntype FileUploadItemProps = Omit<React.ComponentProps<\"li\">, \"children\"> & {\n  children?: React.ReactNode;\n  file: File;\n};\n\nfunction FileUploadItem({ children, className, file, ...props }: FileUploadItemProps) {\n  const { disabled, removeFile } = useFileUpload();\n  const extension = file.name.includes(\".\") ? file.name.split(\".\").pop() : undefined;\n\n  return (\n    <li\n      data-slot=\"file-upload-item\"\n      className={cn(\n        \"flex min-w-0 items-center gap-3 border-b border-border p-3 last:border-b-0\",\n        className,\n      )}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <span className=\"flex size-9 shrink-0 items-center justify-center border border-border bg-muted/30 text-muted-foreground\">\n            <FileIcon className=\"size-4\" aria-hidden=\"true\" />\n          </span>\n          <span className=\"min-w-0 flex-1\">\n            <span className=\"block truncate text-sm font-medium\">{file.name}</span>\n            <span className=\"mt-0.5 block font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground\">\n              {extension ?? \"File\"} · {formatBytes(file.size)}\n            </span>\n          </span>\n          <button\n            type=\"button\"\n            data-slot=\"file-upload-remove\"\n            className=\"flex size-9 shrink-0 cursor-pointer items-center justify-center border border-transparent text-muted-foreground outline-none transition-[background-color,border-color,box-shadow,color] duration-100 hover:border-border hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none\"\n            aria-label={`Remove ${file.name}`}\n            disabled={disabled}\n            onClick={() => removeFile(file)}\n          >\n            <X className=\"size-4\" aria-hidden=\"true\" />\n          </button>\n        </>\n      )}\n    </li>\n  );\n}\n\nfunction FileUploadMessage({ children, className, role, ...props }: React.ComponentProps<\"p\">) {\n  const { error, errorId } = useFileUpload();\n  const content = children ?? error;\n\n  if (!content) {\n    return null;\n  }\n\n  return (\n    <p\n      id={errorId}\n      data-slot=\"file-upload-message\"\n      role={role ?? (error ? \"alert\" : undefined)}\n      className={cn(\"mt-2 text-sm leading-relaxed text-destructive\", className)}\n      {...props}\n    >\n      {content}\n    </p>\n  );\n}\n\nfunction getFileKey(file: File) {\n  return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;\n}\n\nfunction hasFiles(dataTransfer: DataTransfer) {\n  return Array.from(dataTransfer.types).includes(\"Files\");\n}\n\nfunction syncInputFiles(input: HTMLInputElement | null, files: File[]) {\n  if (!input) {\n    return;\n  }\n\n  if (files.length === 0) {\n    input.value = \"\";\n    return;\n  }\n\n  if (typeof DataTransfer === \"undefined\") {\n    return;\n  }\n\n  try {\n    const transfer = new DataTransfer();\n    files.forEach((file) => {\n      transfer.items.add(file);\n    });\n    input.files = transfer.files;\n  } catch {\n    // Some browsers expose FileList as read-only. The React value remains authoritative.\n  }\n}\n\nfunction getFileRejection(\n  file: File,\n  accept?: string,\n  maxSize?: number,\n): FileUploadRejection | null {\n  if (accept && !matchesAccept(file, accept)) {\n    return {\n      file,\n      code: \"file-type-not-accepted\",\n      message: `${file.name} does not match ${formatAccept(accept)}.`,\n    };\n  }\n\n  if (maxSize && file.size > maxSize) {\n    return {\n      file,\n      code: \"file-too-large\",\n      message: `${file.name} exceeds ${formatBytes(maxSize)}.`,\n    };\n  }\n\n  return null;\n}\n\nfunction matchesAccept(file: File, accept: string) {\n  const fileName = file.name.toLowerCase();\n  const fileType = file.type.toLowerCase();\n  const acceptedValues = accept\n    .split(\",\")\n    .map((value) => value.trim().toLowerCase())\n    .filter(Boolean);\n\n  if (acceptedValues.length === 0) {\n    return true;\n  }\n\n  return acceptedValues.some((value) => {\n    if (value.startsWith(\".\")) {\n      return fileName.endsWith(value);\n    }\n\n    if (value.endsWith(\"/*\")) {\n      return fileType.startsWith(value.slice(0, -1));\n    }\n\n    return fileType === value;\n  });\n}\n\nfunction formatAccept(accept: string) {\n  return accept\n    .split(\",\")\n    .map((value) => value.trim().replace(/^\\./, \"\"))\n    .filter(Boolean)\n    .join(\" / \")\n    .toUpperCase();\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes === 0) {\n    return \"0 B\";\n  }\n\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);\n  const value = bytes / 1024 ** index;\n  const formattedValue = Number.isInteger(value)\n    ? value.toString()\n    : value >= 10\n      ? Math.round(value).toString()\n      : value.toFixed(1);\n\n  return `${formattedValue} ${units[index]}`;\n}\n\nexport type {\n  FileUploadDropzoneProps,\n  FileUploadDropzoneSize,\n  FileUploadErrorCode,\n  FileUploadItemProps,\n  FileUploadListProps,\n  FileUploadProps,\n  FileUploadRejection,\n};\nexport {\n  FileUpload,\n  FileUploadDropzone,\n  FileUploadItem,\n  FileUploadList,\n  FileUploadMessage,\n  useFileUpload,\n};\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}