# File Upload

A drop zone or button for choosing files, and a list of the files chosen.

Source: [file-upload.tsx](https://github.com/jakejarvis/jarvis-ui/blob/main/src/components/ui/file-upload.tsx)

```tsx
import { UploadIcon } from "lucide-react";

import {
  FileUpload,
  FileUploadDropzone,
  FileUploadItem,
  FileUploadItemMetadata,
  FileUploadItemPreview,
  FileUploadItemRemove,
  FileUploadList,
  formatFileSize,
} from "@/components/ui/file-upload";

const maxSize = 10_000_000;

export default function FileUploadBasic() {
  return (
    <FileUpload multiple accept="image/*,.pdf" maxSize={maxSize} className="max-w-sm">
      <FileUploadDropzone>
        <UploadIcon />
        <span className="font-medium text-foreground">Drop files or click to browse</span>
        <span className="text-2xs">Images and PDFs, up to {formatFileSize(maxSize)}</span>
      </FileUploadDropzone>
      <FileUploadList>
        {(file) => (
          <FileUploadItem file={file}>
            <FileUploadItemPreview />
            <FileUploadItemMetadata />
            <FileUploadItemRemove />
          </FileUploadItem>
        )}
      </FileUploadList>
    </FileUpload>
  );
}
```

## Installation

Add the registry, once per project:

```bash
npx shadcn@latest registry add @jarvis=https://ui.jarv.is/r/{name}.json
```

Then add the component:

```bash
npx shadcn@latest add @jarvis/file-upload
```

## Usage

```tsx
import { UploadIcon } from "lucide-react";

import {
  FileUpload,
  FileUploadDropzone,
  FileUploadItem,
  FileUploadItemMetadata,
  FileUploadItemPreview,
  FileUploadItemRemove,
  FileUploadList,
} from "@/components/ui/file-upload";

export function Attachments() {
  return (
    <FileUpload multiple accept="image/*,.pdf">
      <FileUploadDropzone>
        <UploadIcon />
        <span className="font-medium text-foreground">Drop files or click to browse</span>
      </FileUploadDropzone>
      <FileUploadList>
        {(file) => (
          <FileUploadItem file={file}>
            <FileUploadItemPreview />
            <FileUploadItemMetadata />
            <FileUploadItemRemove />
          </FileUploadItem>
        )}
      </FileUploadList>
    </FileUpload>
  );
}
```

`FileUpload` holds the chosen files as `File` objects. It doesn't upload them: send them yourself,
or give it a `name` to submit them with a form.

`FileUploadDropzone` accepts dropped files and opens the file picker on click. It's a button, so
use spans inside it. `FileUploadList` renders its child for each file. `FileUploadItemMetadata`
shows the file's name and size, and cuts a long name in the middle with
[Truncate](https://ui.jarv.is/components/truncate.md), so its extension stays in sight.

`accept`, `maxSize` and `maxFiles` reject files, and `onFileReject` tells you which and why.

## Composition

- `FileUpload`
  - `FileUploadDropzone`
  - `FileUploadTrigger`
  - `FileUploadList`
    - `FileUploadItem`
      - `FileUploadItemPreview`
      - `FileUploadItemMetadata`
      - `FileUploadItemProgress`
      - `FileUploadItemRemove`

## Examples

### Upload progress

Put a `FileUploadItemProgress` in `FileUploadItemMetadata` to show progress while a file uploads.

```tsx
import { UploadIcon } from "lucide-react";
import * as React from "react";

import {
  FileUpload,
  FileUploadDropzone,
  FileUploadItem,
  FileUploadItemMetadata,
  FileUploadItemPreview,
  FileUploadItemProgress,
  FileUploadItemRemove,
  FileUploadList,
} from "@/components/ui/file-upload";

// Stands in for a real upload, which would report progress from XMLHttpRequest's upload events.
function fakeUpload(onProgress: (percent: number) => void) {
  let percent = 0;
  const timer = setInterval(() => {
    percent = Math.min(percent + 6 + Math.random() * 14, 100);
    onProgress(Math.round(percent));
    if (percent === 100) clearInterval(timer);
  }, 300);
}

export default function FileUploadWithProgress() {
  const [files, setFiles] = React.useState<File[]>([]);
  const [progress, setProgress] = React.useState(() => new Map<File, number>());

  function addFiles(next: File[]) {
    for (const file of next) {
      if (files.includes(file)) continue;
      fakeUpload((percent) => setProgress((current) => new Map(current).set(file, percent)));
    }
    setFiles(next);
  }

  return (
    <FileUpload multiple value={files} onValueChange={addFiles} className="max-w-sm">
      <FileUploadDropzone>
        <UploadIcon />
        <span className="font-medium text-foreground">Drop files or click to browse</span>
      </FileUploadDropzone>
      <FileUploadList>
        {(file) => {
          const percent = progress.get(file) ?? 0;
          return (
            <FileUploadItem file={file}>
              <FileUploadItemPreview />
              <FileUploadItemMetadata>
                {percent < 100 ? <FileUploadItemProgress value={percent} /> : null}
              </FileUploadItemMetadata>
              <FileUploadItemRemove />
            </FileUploadItem>
          );
        }}
      </FileUploadList>
    </FileUpload>
  );
}
```

### With a label

Point a `FieldLabel` at the dropzone's `id`, or leave out `htmlFor` and the label names the dropzone
and the trigger itself. Set `invalid` on the `Field` and explain a rejected file with a `FieldError`.
The dropzone and the trigger read out the field's `FieldDescription` and `FieldError`, and turn
invalid or disabled with the field.

```tsx
import { UploadIcon } from "lucide-react";
import * as React from "react";

import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import {
  FileUpload,
  FileUploadDropzone,
  FileUploadItem,
  FileUploadItemMetadata,
  FileUploadItemPreview,
  FileUploadItemRemove,
  FileUploadList,
} from "@/components/ui/file-upload";

export default function FileUploadWithField() {
  const [error, setError] = React.useState<string | null>(null);

  return (
    <Field invalid={!!error} className="w-full max-w-sm">
      <FieldLabel htmlFor="contacts-file">Import contacts</FieldLabel>
      <FileUpload
        accept=".csv"
        maxSize={5_000_000}
        onValueChange={() => setError(null)}
        onFileReject={(file, reason) =>
          setError(
            reason === "type" ? `${file.name} isn't a CSV file.` : `${file.name} is over 5 MB.`,
          )
        }
      >
        <FileUploadDropzone id="contacts-file">
          <UploadIcon />
          <span className="font-medium text-foreground">Drop a CSV file or click to browse</span>
        </FileUploadDropzone>
        <FileUploadList>
          {(file) => (
            <FileUploadItem file={file}>
              <FileUploadItemPreview />
              <FileUploadItemMetadata />
              <FileUploadItemRemove />
            </FileUploadItem>
          )}
        </FileUploadList>
      </FileUpload>
      {error ? (
        <FieldError>{error}</FieldError>
      ) : (
        <FieldDescription>One contact per row, up to 5 MB.</FieldDescription>
      )}
    </Field>
  );
}
```

### In a form

Give a `Field` or the upload a `name`, and the files submit under it, each as its own value, so
`formData.getAll(name)` returns them. Pass `form` with a form's `id` when the upload sits outside
it. With `required`, the form won't submit without a file, which fails as `valueMissing`. A form
reset brings back `defaultValue`. A `Form`'s `onFormSubmit` gets the `File` objects as an array.

```tsx
import { UploadIcon } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import {
  FileUpload,
  FileUploadDropzone,
  FileUploadItem,
  FileUploadItemMetadata,
  FileUploadItemPreview,
  FileUploadItemRemove,
  FileUploadList,
} from "@/components/ui/file-upload";
import { Form } from "@/components/ui/form";
import { toast } from "@/components/ui/toast";

export default function FileUploadForm() {
  return (
    <Form
      className="w-full max-w-sm"
      onFormSubmit={(values) => {
        const [resume] = values.resume as File[];
        toast.add({ title: "Application sent", description: `With ${resume?.name}.` });
      }}
    >
      <Field name="resume">
        <FieldLabel>Résumé</FieldLabel>
        <FileUpload accept=".pdf" required>
          <FileUploadDropzone>
            <UploadIcon />
            <span className="font-medium text-foreground">Drop a PDF or click to browse</span>
          </FileUploadDropzone>
          <FileUploadList>
            {(file) => (
              <FileUploadItem file={file}>
                <FileUploadItemPreview />
                <FileUploadItemMetadata />
                <FileUploadItemRemove />
              </FileUploadItem>
            )}
          </FileUploadList>
        </FileUpload>
        <FieldDescription>One PDF, sent with your application.</FieldDescription>
        <FieldError match="valueMissing">Add your résumé.</FieldError>
      </Field>
      <Button type="submit" variant="primary" className="self-start">
        Send application
      </Button>
    </Form>
  );
}
```

### With a button

`FileUploadTrigger` opens the file picker from a button, when a drop zone would take too much room.

```tsx
import { PaperclipIcon } from "lucide-react";

import {
  FileUpload,
  FileUploadItem,
  FileUploadItemMetadata,
  FileUploadItemPreview,
  FileUploadItemRemove,
  FileUploadList,
  FileUploadTrigger,
} from "@/components/ui/file-upload";

export default function FileUploadWithButton() {
  return (
    <FileUpload multiple maxFiles={3} className="max-w-sm">
      <FileUploadTrigger>
        <PaperclipIcon />
        Attach files
      </FileUploadTrigger>
      <FileUploadList>
        {(file) => (
          <FileUploadItem file={file}>
            <FileUploadItemPreview />
            <FileUploadItemMetadata />
            <FileUploadItemRemove />
          </FileUploadItem>
        )}
      </FileUploadList>
    </FileUpload>
  );
}
```

### Disabled

```tsx
import { UploadIcon } from "lucide-react";

import { FileUpload, FileUploadDropzone } from "@/components/ui/file-upload";

export default function FileUploadDisabled() {
  return (
    <FileUpload disabled className="max-w-sm">
      <FileUploadDropzone>
        <UploadIcon />
        <span className="font-medium text-foreground">Uploads are paused</span>
        <span className="text-2xs">Your workspace is out of storage.</span>
      </FileUploadDropzone>
    </FileUpload>
  );
}
```

## Props

The file also exports `formatFileSize(bytes)`, which returns a size such as "2.4 MB".

| Part | Extends |
| --- | --- |
| `FileUpload` | `<div>` |
| `FileUploadDropzone` | `<button>` |
| `FileUploadTrigger` | [`Button`](https://ui.jarv.is/components/button.md) |
| `FileUploadList` | `<ul>` |
| `FileUploadItem` | `<li>` |
| `FileUploadItemPreview` | `<span>` |
| `FileUploadItemMetadata` | `<div>` |
| `FileUploadItemProgress` | [`Progress.Root`](https://base-ui.com/react/components/progress#root) |
| `FileUploadItemRemove` | [`Button`](https://ui.jarv.is/components/button.md) |

The tables leave out `className`, `style` and `render`. Parts built on Base UI take them as its [styling](https://base-ui.com/react/handbook/styling) and [composition](https://base-ui.com/react/handbook/composition) guides describe.

### FileUpload

| Prop             | Type                                               | Description                                                                                              |
| ---------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `value?`         | `File[]`                                           | The chosen files.                                                                                        |
| `defaultValue?`  | `File[]`                                           |                                                                                                          |
| `onValueChange?` | `((files: File[]) => void)`                        | Called with every file after one is added or removed, and with `defaultValue` when the form resets.      |
| `accept?`        | `string`                                           | File types to take, as for a file input: extensions such as `.pdf`, or types such as `image/*`.          |
| `multiple?`      | `boolean`                                          | Whether people can choose more than one file. Without it, a new file replaces the last. Default: `false` |
| `maxFiles?`      | `number`                                           | The most files to take at once, with `multiple`.                                                         |
| `maxSize?`       | `number`                                           | The largest file to take, in bytes.                                                                      |
| `onFileReject?`  | `((file: File, reason: FileRejectReason) => void)` | Called for each file turned away, with why: its `type`, its `size`, or the file `count`.                 |
| `disabled?`      | `boolean`                                          | Default: `false`                                                                                         |
| `name?`          | `string`                                           | The name the files submit under in a form. A `Field`'s `name` takes its place.                           |
| `form?`          | `string`                                           | The `id` of the form to submit the files with, when the upload isn't inside it.                          |
| `required?`      | `boolean`                                          | Whether a form can't be submitted without a file. Default: `false`                                       |

### FileUploadTrigger

| Prop                     | Type                                                                                 | Description                                                                                                                                                                                       |
| ------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `focusableWhenDisabled?` | `boolean`                                                                            | Whether the button should be focusable when disabled. Default: `false`                                                                                                                            |
| `nativeButton?`          | `boolean`                                                                            | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). Default: `true` |
| `variant?`               | `"destructive" \| "ghost" \| "link" \| "outline" \| "primary" \| "secondary"`        | Default: `"secondary"`                                                                                                                                                                            |
| `size?`                  | `"default" \| "icon" \| "icon-lg" \| "icon-sm" \| "icon-xs" \| "lg" \| "sm" \| "xs"` | Default: `"default"`                                                                                                                                                                              |

### FileUploadList

| Prop       | Type                                             | Description                                       |
| ---------- | ------------------------------------------------ | ------------------------------------------------- |
| `children` | `(file: File, index: number) => React.ReactNode` | Renders each file, usually as a `FileUploadItem`. |

### FileUploadItem

| Prop   | Type   | Description               |
| ------ | ------ | ------------------------- |
| `file` | `File` | The file this item shows. |

### FileUploadItemProgress

| Prop                | Type                                                          | Description                                                                                                                                |
| ------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `aria-valuetext?`   | `string`                                                      | A string value that provides a user-friendly name for `aria-valuenow`, the current value of the progress bar.                              |
| `format?`           | `Intl.NumberFormatOptions`                                    | Options to format the value.                                                                                                               |
| `getAriaValueText?` | `((formattedValue: string, value: number \| null) => string)` | Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the progress bar. |
| `locale?`           | `Intl.LocalesArgument`                                        | The locale used by `Intl.NumberFormat` when formatting the value. Defaults to the user's runtime locale.                                   |
| `max?`              | `number`                                                      | The maximum value. Default: `100`                                                                                                          |
| `min?`              | `number`                                                      | The minimum value. Default: `0`                                                                                                            |
| `value`             | `number \| null`                                              | The current value. The component is indeterminate when value is `null`.                                                                    |

### FileUploadItemRemove

| Prop                     | Type                                                                                 | Description                                                                                                                                                                                       |
| ------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `focusableWhenDisabled?` | `boolean`                                                                            | Whether the button should be focusable when disabled. Default: `false`                                                                                                                            |
| `nativeButton?`          | `boolean`                                                                            | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). Default: `true` |
| `variant?`               | `"destructive" \| "ghost" \| "link" \| "outline" \| "primary" \| "secondary"`        | Default: `"ghost"`                                                                                                                                                                                |
| `size?`                  | `"default" \| "icon" \| "icon-lg" \| "icon-sm" \| "icon-xs" \| "lg" \| "sm" \| "xs"` | Default: `"icon-sm"`                                                                                                                                                                              |
