{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "File Upload",
  "author": "@fma-ds/ds",
  "description": "Picks files by dragging them in, pasting them or browsing, and lists them; it doesn't upload.\n\n`FileUpload` holds the `use-file-upload` state — `accept` (`.pdf,.docx`, `image/*`), `maxSize`, `multiple` + `maxFiles`, uncontrolled (`defaultValue`) or controlled (`value`/`onChange`), `isDisabled` — and the parts read it:\n\n- **`FileUploadDropzone`**: a react-aria `DropZone`, with keyboard and paste support. By default an icon, title, `description` and a trigger, highlighted with `accent` while a drag is over it.\n- **`FileUploadTrigger`**: a `FileTrigger` button.\n- **`FileUploadList`**: renders each file with a function.\n- **`FileUploadItem`**: a file-type icon or image thumbnail, name, size, optional `progress`, `error`, extra `actions` such as retry, and a remove button that keeps focus in the widget.\n- **`FileUploadError`**: a `FieldError` with the validation errors (type, size, count).\n\nUpload progress and retries stay with the app, keyed by file id.\n\nBuilt-in text is in English; translate labels and error messages with `i18n`.",
  "dependencies": [
    "cn@^0.5.0",
    "lucide-react@^1.61.0",
    "react-aria-components@^1.22.1"
  ],
  "registryDependencies": [
    "@fma-ds/button",
    "@fma-ds/field",
    "@fma-ds/file-type",
    "@fma-ds/icon-tile",
    "@fma-ds/progress",
    "@fma-ds/use-file-upload"
  ],
  "files": [
    {
      "path": "src/core/file-upload/file-upload.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"cn\";\nimport { UploadIcon, XIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { DropZone, FileTrigger, Text } from \"react-aria-components\";\nimport { Button } from \"@/core/button/button\";\nimport { FieldError } from \"@/core/field/field\";\nimport { FileType, getFileType } from \"@/core/file-type/file-type\";\nimport { IconTile } from \"@/core/icon-tile/icon-tile\";\nimport { Progress } from \"@/core/progress/progress\";\nimport {\n  type FileUploadController,\n  type FileUploadEntry,\n  type FileUploadMessages,\n  formatBytes,\n  type UseFileUploadOptions,\n  useFileUpload,\n} from \"@/hooks/use-file-upload\";\n\ntype FileUploadLabels = {\n  /** The drop zone's name, also its visible title. */\n  dropzone: string;\n  dropzoneMultiple: string;\n  browse: string;\n  browseMultiple: string;\n  remove: (name: string) => string;\n  progress: (name: string) => string;\n};\n\ntype FileUploadI18n = {\n  labels?: Partial<FileUploadLabels>;\n  /** The validation errors (see `use-file-upload`). */\n  messages?: Partial<FileUploadMessages>;\n};\n\nconst defaultFileUploadLabels: FileUploadLabels = {\n  dropzone: \"Drag and drop a file here\",\n  dropzoneMultiple: \"Drag and drop files here\",\n  browse: \"Choose file\",\n  browseMultiple: \"Choose files\",\n  remove: (name) => `Remove ${name}`,\n  progress: (name) => `Uploading ${name}`,\n};\n\ntype FileUploadContextValue = FileUploadController & {\n  labels: FileUploadLabels;\n  rootRef: React.RefObject<HTMLDivElement | null>;\n};\n\nconst FileUploadContext = React.createContext<FileUploadContextValue | null>(null);\n\nfunction useFileUploadContext() {\n  const context = React.useContext(FileUploadContext);\n  if (!context) throw new Error(\"FileUpload parts must be used within a FileUpload.\");\n  return context;\n}\n\ntype FileUploadProps = Omit<UseFileUploadOptions, \"messages\" | \"onChange\"> &\n  Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"onError\"> & {\n    onChange?: (files: FileUploadEntry[]) => void;\n    i18n?: FileUploadI18n;\n  };\n\n/**\n * Picks files by dropping, pasting or browsing, and lists them. Holds the\n * `use-file-upload` state; the parts inside (`FileUploadDropzone`,\n * `FileUploadTrigger`, `FileUploadList`, `FileUploadError`) read it.\n */\nfunction FileUpload({\n  accept,\n  maxSize,\n  maxFiles,\n  multiple,\n  value,\n  defaultValue,\n  onChange,\n  onFilesAdded,\n  onError,\n  isDisabled,\n  i18n,\n  className,\n  ...props\n}: FileUploadProps) {\n  const upload = useFileUpload({\n    accept,\n    maxSize,\n    maxFiles,\n    multiple,\n    value,\n    defaultValue,\n    onChange,\n    onFilesAdded,\n    onError,\n    isDisabled,\n    messages: i18n?.messages,\n  });\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const labelOverrides = i18n?.labels;\n  const labels = React.useMemo(\n    () => ({ ...defaultFileUploadLabels, ...labelOverrides }),\n    [labelOverrides],\n  );\n  const context = { ...upload, labels, rootRef };\n\n  return (\n    <FileUploadContext.Provider value={context}>\n      <div\n        ref={rootRef}\n        data-slot=\"file-upload\"\n        data-invalid={upload.errors.length > 0 || undefined}\n        data-disabled={upload.isDisabled || undefined}\n        className={cn(\"group/file-upload flex w-full min-w-0 flex-col gap-3\", className)}\n        {...props}\n      />\n    </FileUploadContext.Provider>\n  );\n}\n\nconst acceptedTypes = (accept: string) =>\n  accept === \"*\"\n    ? undefined\n    : accept\n        .split(\",\")\n        .map((type) => type.trim())\n        .filter(Boolean);\n\n/** A button that opens the file picker; defaults to an outline \"Choose file\" button. */\nfunction FileUploadTrigger({\n  variant = \"outline\",\n  size = \"sm\",\n  children,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { accept, multiple, isDisabled, addFiles, labels } = useFileUploadContext();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  return (\n    <FileTrigger\n      ref={inputRef}\n      acceptedFileTypes={acceptedTypes(accept)}\n      allowsMultiple={multiple}\n      onSelect={(list) => {\n        if (list) addFiles(list);\n        // Picking the same file again must still fire a change.\n        if (inputRef.current) inputRef.current.value = \"\";\n      }}\n    >\n      <Button\n        data-slot=\"file-upload-trigger\"\n        variant={variant}\n        size={size}\n        isDisabled={isDisabled}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <UploadIcon data-icon=\"inline-start\" />\n            {multiple ? labels.browseMultiple : labels.browse}\n          </>\n        )}\n      </Button>\n    </FileTrigger>\n  );\n}\n\n/**\n * The drop target: drag files onto it, paste them while it has focus, or use\n * its trigger. Without children it shows an icon, the title, `description`\n * (accepted types, size limit) and a `FileUploadTrigger`.\n */\nfunction FileUploadDropzone({\n  description,\n  className,\n  children,\n  ...props\n}: Omit<React.ComponentProps<typeof DropZone>, \"className\" | \"children\"> & {\n  description?: React.ReactNode;\n  className?: string;\n  children?: React.ReactNode;\n}) {\n  const { multiple, isDisabled, addFiles, labels } = useFileUploadContext();\n\n  return (\n    <DropZone\n      data-slot=\"file-upload-dropzone\"\n      isDisabled={isDisabled}\n      onDrop={async (event) => {\n        const items = event.items.filter((item) => item.kind === \"file\");\n        addFiles(await Promise.all(items.map((item) => item.getFile())));\n      }}\n      className={cn(\n        \"flex min-w-0 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-input p-6 text-center text-sm transition-colors outline-none\",\n        \"data-drop-target:border-primary data-drop-target:bg-accent data-drop-target:text-accent-foreground\",\n        \"data-focus-visible:border-ring data-focus-visible:ring-3 data-focus-visible:ring-ring/50\",\n        \"data-disabled:opacity-50 group-data-invalid/file-upload:border-destructive\",\n        className,\n      )}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <IconTile variant=\"outline\">\n            <UploadIcon />\n          </IconTile>\n          <div className=\"flex flex-col gap-1\">\n            <Text slot=\"label\" className=\"font-medium\">\n              {multiple ? labels.dropzoneMultiple : labels.dropzone}\n            </Text>\n            {description && <p className=\"text-muted-foreground\">{description}</p>}\n          </div>\n          <FileUploadTrigger />\n        </>\n      )}\n    </DropZone>\n  );\n}\n\n/**\n * The picked files. Pass a function to render each one (usually a\n * `FileUploadItem`); renders nothing while the list is empty.\n */\nfunction FileUploadList({\n  className,\n  children,\n  ...props\n}: Omit<React.ComponentProps<\"ul\">, \"children\"> & {\n  children: (file: FileUploadEntry) => React.ReactNode;\n}) {\n  const { files } = useFileUploadContext();\n  if (files.length === 0) return null;\n\n  return (\n    <ul data-slot=\"file-upload-list\" className={cn(\"flex flex-col gap-2\", className)} {...props}>\n      {files.map((file) => (\n        <React.Fragment key={file.id}>{children(file)}</React.Fragment>\n      ))}\n    </ul>\n  );\n}\n\nconst isImage = (file: FileUploadEntry[\"file\"]) => file.type.startsWith(\"image/\");\n\nfunction useObjectUrl(file: File | undefined) {\n  const [url, setUrl] = React.useState<string>();\n  React.useEffect(() => {\n    if (!file) return;\n    const objectUrl = URL.createObjectURL(file);\n    setUrl(objectUrl);\n    return () => {\n      URL.revokeObjectURL(objectUrl);\n      setUrl(undefined);\n    };\n  }, [file]);\n  return url;\n}\n\nfunction FileUploadItemMedia({\n  file,\n  preview,\n}: {\n  file: FileUploadEntry[\"file\"];\n  preview: boolean;\n}) {\n  const showImage = preview && isImage(file);\n  const objectUrl = useObjectUrl(showImage && file instanceof File ? file : undefined);\n  const src = showImage ? (file instanceof File ? objectUrl : file.url) : undefined;\n\n  if (src) {\n    return (\n      <span className=\"size-10 shrink-0 overflow-hidden rounded-md border bg-muted\">\n        <img src={src} alt=\"\" className=\"size-full object-cover\" />\n      </span>\n    );\n  }\n  return <FileType type={getFileType(file.name)} variant=\"light\" size=\"default\" />;\n}\n\n/**\n * One picked file: icon or image preview, name, size, an optional upload\n * `progress` (0–100) and a remove button. `error` replaces the size with a\n * message (a failed upload); `actions` adds buttons before remove (retry).\n */\nfunction FileUploadItem({\n  file: entry,\n  progress,\n  error,\n  description,\n  actions,\n  preview = true,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"li\">, \"children\"> & {\n  file: FileUploadEntry;\n  progress?: number;\n  error?: React.ReactNode;\n  /** Replaces the size line. */\n  description?: React.ReactNode;\n  actions?: React.ReactNode;\n  /** Show images as a thumbnail instead of the file-type icon. */\n  preview?: boolean;\n}) {\n  const { removeFile, isDisabled, labels, rootRef } = useFileUploadContext();\n  const itemRef = React.useRef<HTMLLIElement>(null);\n  const { file } = entry;\n\n  function remove() {\n    // Keep focus in the widget: the next item, the previous one, or the trigger.\n    const item = itemRef.current;\n    const sibling = item?.nextElementSibling ?? item?.previousElementSibling;\n    const target =\n      sibling?.querySelector<HTMLElement>(\"[data-slot=file-upload-remove]\") ??\n      rootRef.current?.querySelector<HTMLElement>(\"[data-slot=file-upload-trigger]\");\n    removeFile(entry.id);\n    target?.focus();\n  }\n\n  return (\n    <li\n      ref={itemRef}\n      data-slot=\"file-upload-item\"\n      data-invalid={error ? true : undefined}\n      className={cn(\n        \"flex min-w-0 flex-col gap-2.5 rounded-lg border px-3 py-2.5 text-sm data-invalid:border-destructive/40\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"flex min-w-0 items-center gap-3\">\n        <FileUploadItemMedia file={file} preview={preview} />\n        <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n          <span className=\"truncate font-medium\" title={file.name}>\n            {file.name}\n          </span>\n          <span\n            data-slot=\"file-upload-item-description\"\n            className={cn(\"text-xs text-muted-foreground\", error && \"text-destructive\")}\n          >\n            {error ?? description ?? formatBytes(file.size)}\n          </span>\n        </div>\n        {actions && <div className=\"flex shrink-0 items-center gap-1\">{actions}</div>}\n        <Button\n          data-slot=\"file-upload-remove\"\n          variant=\"ghost\"\n          size=\"icon-sm\"\n          aria-label={labels.remove(file.name)}\n          isDisabled={isDisabled}\n          onPress={remove}\n        >\n          <XIcon />\n        </Button>\n      </div>\n      {progress !== undefined && (\n        <Progress value={progress} aria-label={labels.progress(file.name)} />\n      )}\n    </li>\n  );\n}\n\n/** The last batch's validation errors, in a `FieldError` (`role=\"alert\"`). */\nfunction FileUploadError(props: Omit<React.ComponentProps<typeof FieldError>, \"errors\">) {\n  const { errors } = useFileUploadContext();\n  return <FieldError errors={errors.map(({ message }) => ({ message }))} {...props} />;\n}\n\nexport type { FileUploadI18n, FileUploadLabels, FileUploadProps };\nexport {\n  FileUpload,\n  FileUploadDropzone,\n  FileUploadError,\n  FileUploadItem,\n  FileUploadList,\n  FileUploadTrigger,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ds/file-upload.tsx"
    }
  ],
  "meta": {
    "status": "stable",
    "source": "original",
    "license": "MIT",
    "tags": [
      "form",
      "file",
      "upload",
      "dropzone",
      "react-aria"
    ],
    "contentHash": "c41bcda28536"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}