# File upload

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/file-upload
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=291-146

```tsx
'use client';

import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import {
  FileUpload,
  type UploadFileItem,
} from '@commenda/pathfinder/file-upload';

// Layout stays inline so copied examples work without documentation-only CSS.
const FileUploadBasic = () => {
  const [files, setFiles] = useState<UploadFileItem[]>([]);

  const onRemove = (id: string) => {
    setFiles((current) => current.filter((file) => file.id !== id));
  };

  const onRetry = (id: string) => {
    setFiles((current) =>
      current.map((file) =>
        file.id === id ? { ...file, upload: { status: 'uploading' } } : file
      )
    );
  };

  return (
    <div
      className="pf-upload-basic"
      style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 480 }}
    >
      <FileUpload
        label="Supporting documents"
        files={files}
        onFilesAccepted={(selected) => {
          // Demo only: replace these manual controls with your upload transport.
          setFiles((current) => [
            ...current,
            ...selected.map(
              (file): UploadFileItem => ({
                id: crypto.randomUUID(),
                filename: file.name,
                sizeBytes: file.size,
                upload: { status: 'uploading' },
              })
            ),
          ]);
        }}
        validationConfig={{
          accept: { 'application/pdf': ['.pdf'], 'text/csv': ['.csv'] },
          maxSizeBytes: 10_000_000,
          maxFiles: 5,
        }}
        actionConfig={{ onCancel: onRemove, onRemove, onRetry }}
      />
      <p
        className="pf-demo-note"
        style={{
          margin: 0,
          fontSize: 12,
          lineHeight: 1.6,
          color: 'var(--pf-color-soft-pearl-800)',
        }}
      >
        No files leave your browser. Choose a PDF or CSV, then simulate the
        result.
      </p>
      <div
        className="pf-demo-actions"
        style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
      >
        <Button
          variant="secondary"
          disabled={!files.some((file) => file.upload.status === 'uploading')}
          onClick={() => {
            setFiles((current) =>
              current.map((file) =>
                file.upload.status === 'uploading'
                  ? { ...file, upload: { status: 'complete' } }
                  : file
              )
            );
          }}
        >
          Complete uploads
        </Button>
        <Button
          variant="secondary"
          disabled={!files.some((file) => file.upload.status === 'uploading')}
          onClick={() => {
            setFiles((current) =>
              current.map((file) =>
                file.upload.status === 'uploading'
                  ? {
                      ...file,
                      upload: {
                        status: 'failed',
                        message: 'Upload failed. Retry to try again.',
                      },
                    }
                  : file
              )
            );
          }}
        >
          Fail uploads
        </Button>
      </div>
    </div>
  );
};

export default FileUploadBasic;
```


## Usage [#usage]

```tsx
import {
  FileUpload,
  type UploadFileItem,
} from '@commenda/pathfinder/file-upload';

<FileUpload
  label="Supporting documents"
  files={files}
  onFilesAccepted={startUploads}
  validationConfig={{
    accept: { 'application/pdf': ['.pdf'], 'text/csv': ['.csv'] },
    maxSizeBytes: 10_000_000,
    maxFiles: 5,
  }}
  actionConfig={{
    onCancel: cancelUpload,
    onRetry: retryUpload,
    onRemove: removeFile,
    onPreview: previewFile,
    onDownload: downloadFile,
  }}
/>;
```

The first example keeps files in memory and never uploads them. Pick a PDF or CSV, then use **Complete uploads** or **Fail uploads** to simulate a result. Retry returns a failed card to uploading. Its Code tab includes all state and handlers; replace the simulation with your upload transport.

Append accepted files to controlled `files` immediately, with stable IDs and `upload: { status: 'uploading', progress: 0 }`. Start your upload in `onFilesAccepted`; update progress from actual transport events and set `complete` only after the server confirms success. Retain the original `File` objects in your application for retries. Keep completed and failed cards in selection order, newest last.

## Compact button [#compact-button]


```tsx
'use client';

import { useState } from 'react';

import {
  FileUpload,
  type UploadFileItem,
} from '@commenda/pathfinder/file-upload';

// Layout stays inline so copied examples work without documentation-only CSS.
const FileUploadButton = () => {
  const [files, setFiles] = useState<UploadFileItem[]>([]);

  const onRemove = (id: string) => {
    setFiles((current) => current.filter((file) => file.id !== id));
  };

  const onRetry = (id: string) => {
    setFiles((current) =>
      current.map((file) =>
        file.id === id ? { ...file, upload: { status: 'uploading' } } : file
      )
    );
  };

  return (
    <div
      className="pf-upload-basic"
      style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 480 }}
    >
      <FileUpload
        label="Supporting documents"
        variant="button"
        files={files}
        onFilesAccepted={(selected) => {
          // Demo only: replace these manual controls with your upload transport.
          setFiles((current) => [
            ...current,
            ...selected.map(
              (file): UploadFileItem => ({
                id: crypto.randomUUID(),
                filename: file.name,
                sizeBytes: file.size,
                upload: { status: 'uploading' },
              })
            ),
          ]);
        }}
        validationConfig={{
          accept: { 'application/pdf': ['.pdf'], 'text/csv': ['.csv'] },
          maxSizeBytes: 10_000_000,
          maxFiles: 5,
        }}
        actionConfig={{ onCancel: onRemove, onRemove, onRetry }}
      />
    </div>
  );
};

export default FileUploadButton;
```


Use the button where a dropzone would compete with the form. Limits remain visible beside it and wrap on narrow screens. The dropzone itself is a single native button: its entire area opens the file picker with pointer, Enter, or Space; “browse” is styled text, not a nested button.

## Single file [#single-file]


```tsx
'use client';

import { useState } from 'react';

import {
  FileUpload,
  type UploadFileItem,
} from '@commenda/pathfinder/file-upload';

// Layout stays inline so copied examples work without documentation-only CSS.
const FileUploadSingle = () => {
  const [files, setFiles] = useState<UploadFileItem[]>([]);

  const onRemove = (id: string) => {
    setFiles((current) => current.filter((file) => file.id !== id));
  };

  const onRetry = (id: string) => {
    setFiles((current) =>
      current.map((file) =>
        file.id === id ? { ...file, upload: { status: 'uploading' } } : file
      )
    );
  };

  return (
    <div
      className="pf-upload-basic"
      style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 480 }}
    >
      <FileUpload
        label="Supporting documents"
        files={files}
        onFilesAccepted={(selected) => {
          // Demo only: replace these manual controls with your upload transport.
          setFiles((current) => [
            ...current,
            ...selected.map(
              (file): UploadFileItem => ({
                id: crypto.randomUUID(),
                filename: file.name,
                sizeBytes: file.size,
                upload: { status: 'uploading' },
              })
            ),
          ]);
        }}
        validationConfig={{
          accept: { 'application/pdf': ['.pdf'], 'text/csv': ['.csv'] },
          maxSizeBytes: 10_000_000,
          maxFiles: 1,
        }}
        actionConfig={{ onCancel: onRemove, onRemove, onRetry }}
      />
    </div>
  );
};

export default FileUploadSingle;
```


`validationConfig.maxFiles: 1` replaces the picker with the selected file's card. Cancel or remove that card to choose another file. Supply those callbacks to allow replacement. Multiple-file mode keeps the picker visible; exceeding the limit reports a rejection. Existing uploading, failed, and completed items all count toward the limit.

## Upload states and recovery [#upload-states-and-recovery]


```tsx
'use client';

import { useState } from 'react';

import {
  FileUpload,
  type UploadFileItem,
} from '@commenda/pathfinder/file-upload';

// Layout stays inline so copied examples work without documentation-only CSS.
const FileUploadStates = () => {
  const [files, setFiles] = useState<UploadFileItem[]>([
    {
      id: 'uploading',
      filename: 'Board resolution, signed.pdf',
      sizeBytes: 1200000,
      upload: { status: 'uploading', progress: 64 },
    },
    {
      id: 'complete',
      filename: 'Q3 2026 sales tax return.pdf',
      sizeBytes: 1200000,
      upload: { status: 'complete' },
    },
    {
      id: 'failed',
      filename: 'Nexus study, California.csv',
      sizeBytes: 2000000,
      upload: {
        status: 'failed',
        message: 'Upload failed. Retry to try again.',
      },
    },
  ]);

  const onRemove = (id: string) => {
    setFiles((current) => current.filter((file) => file.id !== id));
  };

  const onRetry = (id: string) => {
    setFiles((current) =>
      current.map((file) =>
        file.id === id ? { ...file, upload: { status: 'uploading' } } : file
      )
    );
  };

  return (
    <div
      className="pf-upload-basic"
      style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 480 }}
    >
      <FileUpload
        label="Supporting documents"
        files={files}
        onFilesAccepted={(selected) => {
          // Demo only: replace these manual controls with your upload transport.
          setFiles((current) => [
            ...current,
            ...selected.map(
              (file): UploadFileItem => ({
                id: crypto.randomUUID(),
                filename: file.name,
                sizeBytes: file.size,
                upload: { status: 'uploading' },
              })
            ),
          ]);
        }}
        validationConfig={{
          accept: { 'application/pdf': ['.pdf'], 'text/csv': ['.csv'] },
          maxSizeBytes: 10_000_000,
          maxFiles: 5,
        }}
        actionConfig={{ onCancel: onRemove, onRemove, onRetry }}
      />
    </div>
  );
};

export default FileUploadStates;
```


Uploading shows progress and Cancel. Completed files offer Preview, Download, and Remove when those callbacks exist. Failed files retain their filename and error with Retry and Remove. Unknown progress is indeterminate; 100% alone does not mean success. No operation blocks the surrounding page. Removing a focused action restores focus to an available control within the uploader.

## Validation and errors [#validation-and-errors]


```tsx
'use client';

import { useState } from 'react';

import {
  FileUpload,
  type UploadFileItem,
} from '@commenda/pathfinder/file-upload';

// Layout stays inline so copied examples work without documentation-only CSS.
const FileUploadError = () => {
  const [files, setFiles] = useState<UploadFileItem[]>([]);

  const onRemove = (id: string) => {
    setFiles((current) => current.filter((file) => file.id !== id));
  };

  const onRetry = (id: string) => {
    setFiles((current) =>
      current.map((file) =>
        file.id === id ? { ...file, upload: { status: 'uploading' } } : file
      )
    );
  };

  return (
    <div
      className="pf-upload-basic"
      style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 480 }}
    >
      <FileUpload
        label="Supporting documents"
        errorConfig={{ message: 'Choose a file smaller than 10 MB.' }}
        files={files}
        onFilesAccepted={(selected) => {
          // Demo only: replace these manual controls with your upload transport.
          setFiles((current) => [
            ...current,
            ...selected.map(
              (file): UploadFileItem => ({
                id: crypto.randomUUID(),
                filename: file.name,
                sizeBytes: file.size,
                upload: { status: 'uploading' },
              })
            ),
          ]);
        }}
        validationConfig={{
          accept: { 'application/pdf': ['.pdf'], 'text/csv': ['.csv'] },
          maxSizeBytes: 10_000_000,
          maxFiles: 5,
        }}
        actionConfig={{ onCancel: onRemove, onRemove, onRetry }}
      />
    </div>
  );
};

export default FileUploadError;
```


Empty files are always rejected. An accept entry matches its MIME type (including a wildcard such as `image/*`) **or** one of its extension fallbacks, case-insensitively. Extensions should include their leading dot. A file exactly at `maxSizeBytes` is accepted. Use positive finite size limits and positive integer file limits.

Validation processes selected files in order: valid files fill remaining capacity; rejected files never enter `onFilesAccepted`. `onFilesRejected` receives `{ file, code, message }` for each rejection, with codes `empty`, `type`, `size`, or `count`. Re-selecting the same file works because the native input is reset. Duplicate filenames are not deduplicated: assign distinct IDs or apply your product's duplicate policy in the host.

Limits are generated from `validationConfig`; `description` can provide localized copy. Built-in error messages are English; use `onFilesRejected` with `errorConfig` for application translations. Browser MIME/extension validation is a convenience, not content verification. The server still enforces size, content, permissions, and scanning. No credential, company ID, storage URL, or API client enters this component.

## Disabled state [#disabled-state]


```tsx
'use client';

import { useState } from 'react';

import {
  FileUpload,
  type UploadFileItem,
} from '@commenda/pathfinder/file-upload';

// Layout stays inline so copied examples work without documentation-only CSS.
const FileUploadDisabled = () => {
  const [files, setFiles] = useState<UploadFileItem[]>([
    {
      id: 'uploading',
      filename: 'Board resolution, signed.pdf',
      sizeBytes: 1200000,
      upload: { status: 'uploading', progress: 64 },
    },
    {
      id: 'complete',
      filename: 'Q3 2026 sales tax return.pdf',
      sizeBytes: 1200000,
      upload: { status: 'complete' },
    },
    {
      id: 'failed',
      filename: 'Nexus study, California.csv',
      sizeBytes: 2000000,
      upload: {
        status: 'failed',
        message: 'Upload failed. Retry to try again.',
      },
    },
  ]);

  const onRemove = (id: string) => {
    setFiles((current) => current.filter((file) => file.id !== id));
  };

  const onRetry = (id: string) => {
    setFiles((current) =>
      current.map((file) =>
        file.id === id ? { ...file, upload: { status: 'uploading' } } : file
      )
    );
  };

  return (
    <div
      className="pf-upload-basic"
      style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 480 }}
    >
      <FileUpload
        label="Supporting documents"
        disableConfig={{
          isDisabled: true,
          reason: 'You have read-only access.',
        }}
        files={files}
        onFilesAccepted={(selected) => {
          // Demo only: replace these manual controls with your upload transport.
          setFiles((current) => [
            ...current,
            ...selected.map(
              (file): UploadFileItem => ({
                id: crypto.randomUUID(),
                filename: file.name,
                sizeBytes: file.size,
                upload: { status: 'uploading' },
              })
            ),
          ]);
        }}
        validationConfig={{
          accept: { 'application/pdf': ['.pdf'], 'text/csv': ['.csv'] },
          maxSizeBytes: 10_000_000,
          maxFiles: 5,
        }}
        actionConfig={{ onCancel: onRemove, onRemove, onRetry }}
      />
    </div>
  );
};

export default FileUploadDisabled;
```

Disabled state prevents file acceptance and card actions. Use a reason to
explain unavailable access. Long original filenames truncate visually with a
native full-name tooltip; error messages wrap. File selection, progress, and
failure are announced through status changes without announcing every progress
percentage.

## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `label` | `string` | required | Required accessible field name |
| `hideLabel` | `boolean` | optional | Visually hide the field label |
| `variant` | `"button" \| "dropzone"` | optional | Picker presentation; defaults to dropzone |
| `files` | `readonly UploadFileItem[]` | required | Controlled list, in host-provided order |
| `onFilesAccepted` | `((files: readonly File[]) => void)` | required | Receive validated files and start host uploads |
| `onFilesRejected` | `((rejections: readonly FileRejection[]) => void)` | optional | Report client-side validation failures |
| `validationConfig` | `FileValidationConfig` | optional | Optional type, size, and count limits |
| `actionConfig` | `FileActionConfig` | optional | Cancel, retry, remove, preview, download callbacks by ID |
| `disableConfig` | `{ isDisabled: boolean; reason?: string; }` | optional | Disable selection and card actions |
| `description` | `string` | optional | Override generated visible limits |
| `errorConfig` | `{ message: string; }` | optional | Host error overrides client validation copy |
| `className` | `string` | optional | Host width/layout; no fixed 480px component width |

### validationConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `accept` | `Readonly<Record<string, readonly string[]>>` | No | MIME types with extension fallbacks, e.g. {'application/pdf': ['.pdf']}. |
| `maxSizeBytes` | `number` | No |  |
| `maxFiles` | `number` | No | Counts all controlled items, including uploading and failed files. One swaps the picker for its card. |

### actionConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `onCancel` | `((id: string) => void)` | No |  |
| `onRetry` | `((id: string) => void)` | No |  |
| `onRemove` | `((id: string) => void)` | No |  |
| `onPreview` | `((id: string) => void)` | No |  |
| `onDownload` | `((id: string) => void)` | No |  |

### disableConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `isDisabled` | `boolean` | Yes |  |
| `reason` | `string` | No |  |

### errorConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | `string` | Yes |  |

## Design rules and integration [#design-rules-and-integration]

- **FILE-UPLOAD-01:** One dropzone per form where space permits; use a button in compact layouts. Show limits before selection. Radius 8px, dashed Pearl 400 border, 24px zone padding, and 6px content gaps follow the rendered frame.
- **FILE-UPLOAD-02:** Accept valid files immediately and append newest last. Reject invalid files before inserting cards; preserve failed uploads for retry.
- **FILE-UPLOAD-03:** The application owns files, progress, upload cancellation, preview/download, and removal persistence. Update controlled files synchronously when accepting a batch; guard concurrent transport operations and ignore late progress after cancel/removal.
- **FILE-UPLOAD-04:** Full-zone keyboard/pointer activation; restore focus on picker cancellation and card removal. Drag-over tint takes 150ms, inserted cards fade/translate 4px for 200ms, and the existing ProgressBar interpolates for 300ms. Reduced motion removes these transitions.

Use `validationConfig` for accepted formats and size/count limits, and `actionConfig` for upload actions. Authentication, storage, signed URLs, and transport remain application-owned. Keep stable file IDs and update `upload.status` as uploads progress. Use [Attachment](/docs/components/attachment) for compact message/composer presentation and [File card](/docs/components/file-card) for upload lifecycle rows.
