Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/headless-file-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This package is **internal** (`private: true`) and consumed by `@clerk/ui`. It e
| Accordion | `@clerk/headless/accordion` | Expandable content sections with single/multiple mode |
| Autocomplete | `@clerk/headless/autocomplete` | Combobox input with filterable option list |
| Dialog | `@clerk/headless/dialog` | Modal dialog with focus trapping and scroll lock |
| FileUpload | `@clerk/headless/file-upload` | File picker + drag-and-drop upload with image previews |
| Menu | `@clerk/headless/menu` | Dropdown and nested context menus with safe hover zones |
| Popover | `@clerk/headless/popover` | Non-modal floating content triggered by click |
| Select | `@clerk/headless/select` | Dropdown select with typeahead and keyboard navigation |
Expand Down
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
},
"./file-upload": {
"import": "./dist/primitives/file-upload/index.js",
"types": "./dist/primitives/file-upload/index.d.ts"
},
"./hooks": {
"import": "./dist/hooks/index.js",
"types": "./dist/hooks/index.d.ts"
Expand Down
164 changes: 164 additions & 0 deletions packages/headless/src/primitives/file-upload/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# FileUpload

A headless file upload primitive. Users can pick files through a `Trigger` button or by dragging
them onto a `Dropzone`, and selected files render as compound `Item` parts with an optional image
`Preview`. Supports controlled/uncontrolled selection, `accept` filtering (applied to both the
picker and dropped files), single/multiple modes, and a disabled state.

## When to Use

- Any file input where you want the native picker behind your own styled trigger.
- Drag-and-drop upload zones with live image thumbnails.
- Avatar / logo pickers (single mode) or multi-file attachment lists (multiple mode).

The primitive owns a hidden `<input type="file">` internally, so you never render one yourself. It
emits zero styles — everything is driven by `data-cl-*` attributes.

## Usage

```tsx
import { FileUpload } from '@clerk/headless/file-upload';

function Uploader() {
return (
<FileUpload.Root
accept='image/*'
multiple
>
<FileUpload.Dropzone>
<FileUpload.Trigger>Choose files</FileUpload.Trigger>
<p>or drag files here</p>
</FileUpload.Dropzone>

<FileList />
</FileUpload.Root>
);
}

// Read the live file list and render each file as an Item.
function FileList() {
const { files } = FileUpload.useFileUpload();
return (
<ul>
{files.map(file => (
<li key={file.name}>
<FileUpload.Item file={file}>
<FileUpload.ItemPreview />
<span>{file.name}</span>
<FileUpload.ItemDelete>Remove</FileUpload.ItemDelete>
</FileUpload.Item>
</li>
))}
</ul>
);
}
```

`ItemPreview` renders an `<img>` only for image files; for other types it renders nothing, so it is
safe to include unconditionally.

### Controlled

```tsx
const [files, setFiles] = useState<File[]>([]);

<FileUpload.Root
value={files}
onValueChange={setFiles}
>
{/* ... */}
</FileUpload.Root>;
```

### Single file (avatar)

```tsx
<FileUpload.Root accept='image/*'>
<FileUpload.Trigger>Upload avatar</FileUpload.Trigger>
</FileUpload.Root>
```

In single mode a new selection replaces the previous file; in multiple mode selections are appended.

### Validation

`accept` and `maxSize` reject non-matching files, and `onReject` reports them so you can show an
error. Rejections fire for both the picker and drops; accepted files in the same batch are still
added.

```tsx
<FileUpload.Root
accept='image/png,image/jpeg'
maxSize={10 * 1000 * 1000}
onReject={rejections => {
for (const { file, reason } of rejections) {
setError(reason === 'size' ? `${file.name} is too large` : `${file.name} is not a supported type`);
}
}}
onValueChange={([file]) => file && upload(file)}
>
<FileUpload.Trigger>Upload</FileUpload.Trigger>
</FileUpload.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------------ | --------------- | ------------------------------------------------------------------- |
| `FileUpload.Root` | `<div>` | Owns the file state + hidden input, provides context |
| `FileUpload.Trigger` | `<button>` | Opens the native file picker |
| `FileUpload.Dropzone` | `<div>` | Drag-and-drop target; filters dropped files by `accept` |
| `FileUpload.Item` | `<div>` | Wraps a single selected file, provides item context |
| `FileUpload.ItemPreview` | `<img>` | Image thumbnail for the item's file (renders nothing for non-image) |
| `FileUpload.ItemDelete` | `<button>` | Removes the item's file |

`FileUpload.useFileUpload()` is a hook (not a component) that returns
`{ files, addFiles, removeFile, clearFiles, openFilePicker, disabled }` for reading the selection
and driving custom UI. It must be called inside `FileUpload.Root`.

## Props

### `FileUpload.Root`

| Prop | Type | Default | Description |
| --------------- | --------------------------------------- | ------- | -------------------------------------------------------------- |
| `value` | `File[]` | — | Controlled list of selected files |
| `defaultValue` | `File[]` | `[]` | Initial list of files (uncontrolled) |
| `onValueChange` | `(files: File[]) => void` | — | Called with the full list whenever it changes |
| `multiple` | `boolean` | `false` | Allow selecting more than one file |
| `accept` | `string` | — | `accept` string (e.g. `image/*,.pdf`), also filters drops |
| `maxSize` | `number` | — | Max size in bytes for a single file; larger files are rejected |
| `onReject` | `(rejections: FileRejection[]) => void` | — | Called with files rejected by `accept` or `maxSize` |
| `disabled` | `boolean` | `false` | Disables the trigger, dropzone, and picker |

A `FileRejection` is `{ file: File; reason: 'accept' | 'size' | 'overflow' }`. Rejections are
reported for both the picker and drops; `accept` is checked before `maxSize`, and in single-file mode
any files beyond the first are reported with reason `'overflow'` rather than dropped silently.
Accepted files in the same batch are still added.

### `FileUpload.Item`

| Prop | Type | Default | Description |
| ------ | ------ | ------- | ----------------------------- |
| `file` | `File` | — | The file this item represents |

Other parts take no additional props. All parts accept a `render` prop for polymorphic rendering and
standard HTML attributes for their default element.

## Data Attributes

| Attribute | Applies To | Description |
| ------------------ | ----------------------------------- | ----------------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"file-upload-dropzone"`) |
| `data-cl-empty` | Root | Present when no files are selected |
| `data-cl-dragging` | Dropzone | Present while a valid drag is over the zone |
| `data-cl-image` | Item | Present when the item's file is an image |
| `data-cl-disabled` | Root, Trigger, Dropzone, ItemDelete | Present when disabled |

## ARIA

- The hidden `<input type="file">` is `aria-hidden` and removed from the tab order; the `Trigger`
button is the accessible control and forwards its click to the input.
- `Trigger` and `ItemDelete` render real `<button type="button">` elements and reflect `disabled`.
- `Dropzone` sets `aria-disabled` when the upload is disabled.
- `ItemPreview` uses the file name as its `alt` text.
40 changes: 40 additions & 0 deletions packages/headless/src/primitives/file-upload/accept.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Matches a file against an `accept` string using the same grammar as the
* native `<input type="file" accept>` attribute: a comma-separated list of
* file extensions (`.png`), exact MIME types (`image/png`), and wildcard MIME
* groups (`image/*`). A missing or empty `accept` accepts everything.
*
* The native file picker already enforces `accept`, but drag-and-drop bypasses
* it, so the Dropzone re-validates dropped files with this helper.
*/
export function isFileAccepted(file: File, accept: string | undefined): boolean {
if (!accept) {
return true;
}

const tokens = accept
.split(',')
.map(token => token.trim().toLowerCase())
.filter(Boolean);

if (tokens.length === 0) {
return true;
}

const fileType = file.type.toLowerCase();
const fileName = file.name.toLowerCase();

return tokens.some(token => {
if (token === '*' || token === '*/*') {
return true;
}
if (token.startsWith('.')) {
return fileName.endsWith(token);
}
if (token.endsWith('/*')) {
// Wildcard MIME group, e.g. `image/*` matches `image/png`.
return fileType.startsWith(`${token.slice(0, -1)}`);
}
return fileType === token;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createContext, useContext } from 'react';

export interface FileUploadContextValue {
/** The currently selected files. */
files: File[];
/** Whether the whole upload is disabled. */
disabled: boolean;
/** Whether more than one file can be selected. */
multiple: boolean;
/** The `accept` string forwarded to the hidden input and used to filter drops. */
accept: string | undefined;
/** Add files, applying `accept` filtering and single/multiple semantics. */
addFiles: (incoming: FileList | File[]) => void;
/** Remove a single file by identity. */
removeFile: (file: File) => void;
/** Remove every selected file. */
clearFiles: () => void;
/** Open the native file picker (clicks the hidden input). */
openFilePicker: () => void;
}

export const FileUploadContext = createContext<FileUploadContextValue | null>(null);

export function useFileUploadContext(): FileUploadContextValue {
const ctx = useContext(FileUploadContext);
if (!ctx) {
throw new Error('FileUpload compound components must be used within <FileUpload.Root>');
}
return ctx;
}

export interface FileUploadItemContextValue {
/** The file this item represents. */
file: File;
/** Remove this file from the upload. */
remove: () => void;
}

export const FileUploadItemContext = createContext<FileUploadItemContextValue | null>(null);

export function useFileUploadItemContext(): FileUploadItemContextValue {
const ctx = useContext(FileUploadItemContext);
if (!ctx) {
throw new Error('FileUpload item parts must be used within <FileUpload.Item>');
}
return ctx;
}

/**
* Reads the current upload state and actions. Use this to render your own list
* of files (wrapping each in `<FileUpload.Item>`), a file count, or a custom
* clear button. Must be called inside `<FileUpload.Root>`.
*/
export function useFileUpload(): Pick<
FileUploadContextValue,
'files' | 'addFiles' | 'removeFile' | 'clearFiles' | 'openFilePicker' | 'disabled'
> {
const { files, addFiles, removeFile, clearFiles, openFilePicker, disabled } = useFileUploadContext();
return { files, addFiles, removeFile, clearFiles, openFilePicker, disabled };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import { type DragEvent, useRef, useState } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useFileUploadContext } from './file-upload-context';

export type FileUploadDropzoneProps = ComponentProps<'div'>;

export function FileUploadDropzone(props: FileUploadDropzoneProps) {
const { render, ...otherProps } = props;
const { disabled, addFiles } = useFileUploadContext();

const [dragging, setDragging] = useState(false);
// Counts enter/leave events so dragging over child elements does not flicker
// the drag state (dragenter/dragleave fire per nested element).
const dragDepth = useRef(0);

const state = { dragging, disabled };

const defaultProps: Record<string, unknown> = {
'data-cl-slot': 'file-upload-dropzone',
'aria-disabled': disabled || undefined,
onDragEnter: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (disabled) {
return;
}
dragDepth.current += 1;
setDragging(true);
},
onDragOver: (event: DragEvent<HTMLDivElement>) => {
// Required for the element to be a valid drop target.
event.preventDefault();
if (!disabled) {
event.dataTransfer.dropEffect = 'copy';
}
},
onDragLeave: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (disabled) {
return;
}
dragDepth.current -= 1;
if (dragDepth.current <= 0) {
dragDepth.current = 0;
setDragging(false);
}
},
onDrop: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
dragDepth.current = 0;
setDragging(false);
if (disabled) {
return;
}
const { files } = event.dataTransfer;
if (files && files.length > 0) {
addFiles(files);
}
},
};

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
dragging: (v: boolean) => (v ? { 'data-cl-dragging': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useFileUploadContext, useFileUploadItemContext } from './file-upload-context';

export type FileUploadItemDeleteProps = ComponentProps<'button'>;

export function FileUploadItemDelete(props: FileUploadItemDeleteProps) {
const { render, ...otherProps } = props;
const { disabled } = useFileUploadContext();
const { remove } = useFileUploadItemContext();

const state = { disabled };

const defaultProps: Record<string, unknown> = {
'data-cl-slot': 'file-upload-item-delete',
type: 'button' as const,
disabled,
onClick: () => {
if (!disabled) {
remove();
}
},
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
Loading
Loading