From 2b9c2f358de52d1f9375730a083dc606ad6e14f2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 1 Jul 2026 15:50:10 -0400 Subject: [PATCH 1/2] feat(headless): add FileUpload compound primitive Adds a headless, unstyled FileUpload primitive (Root, Trigger, Dropzone, Item, ItemPreview, ItemDelete + useFileUpload hook) supporting file-picker and drag-and-drop selection, single/multiple modes, accept and maxSize filtering with an onReject callback, and image previews. Includes a swingset docs page. --- .changeset/headless-file-upload.md | 2 + packages/headless/README.md | 1 + packages/headless/package.json | 4 + .../src/primitives/file-upload/README.md | 163 ++++++++ .../src/primitives/file-upload/accept.ts | 40 ++ .../file-upload/file-upload-context.ts | 60 +++ .../file-upload/file-upload-dropzone.tsx | 74 ++++ .../file-upload/file-upload-item-delete.tsx | 35 ++ .../file-upload/file-upload-item-preview.tsx | 51 +++ .../file-upload/file-upload-item.tsx | 40 ++ .../file-upload/file-upload-root.tsx | 174 ++++++++ .../file-upload/file-upload-trigger.tsx | 30 ++ .../file-upload/file-upload.test.tsx | 371 ++++++++++++++++++ .../src/primitives/file-upload/index.ts | 12 + .../src/primitives/file-upload/parts.ts | 12 + packages/headless/vite.config.ts | 1 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 3 + packages/swingset/src/stories/file-upload.mdx | 159 ++++++++ .../src/stories/file-upload.stories.tsx | 54 +++ 20 files changed, 1287 insertions(+) create mode 100644 .changeset/headless-file-upload.md create mode 100644 packages/headless/src/primitives/file-upload/README.md create mode 100644 packages/headless/src/primitives/file-upload/accept.ts create mode 100644 packages/headless/src/primitives/file-upload/file-upload-context.ts create mode 100644 packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx create mode 100644 packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx create mode 100644 packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx create mode 100644 packages/headless/src/primitives/file-upload/file-upload-item.tsx create mode 100644 packages/headless/src/primitives/file-upload/file-upload-root.tsx create mode 100644 packages/headless/src/primitives/file-upload/file-upload-trigger.tsx create mode 100644 packages/headless/src/primitives/file-upload/file-upload.test.tsx create mode 100644 packages/headless/src/primitives/file-upload/index.ts create mode 100644 packages/headless/src/primitives/file-upload/parts.ts create mode 100644 packages/swingset/src/stories/file-upload.mdx create mode 100644 packages/swingset/src/stories/file-upload.stories.tsx diff --git a/.changeset/headless-file-upload.md b/.changeset/headless-file-upload.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/headless-file-upload.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/README.md b/packages/headless/README.md index f880871432d..09b25b6bf7d 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -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 | diff --git a/packages/headless/package.json b/packages/headless/package.json index 495046e13a7..7a4bb60fffb 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -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" diff --git a/packages/headless/src/primitives/file-upload/README.md b/packages/headless/src/primitives/file-upload/README.md new file mode 100644 index 00000000000..40a166771e2 --- /dev/null +++ b/packages/headless/src/primitives/file-upload/README.md @@ -0,0 +1,163 @@ +# 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 `` 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 ( + + + Choose files +

or drag files here

+
+ + +
+ ); +} + +// Read the live file list and render each file as an Item. +function FileList() { + const { files } = FileUpload.useFileUpload(); + return ( + + ); +} +``` + +`ItemPreview` renders an `` only for image files; for other types it renders nothing, so it is +safe to include unconditionally. + +### Controlled + +```tsx +const [files, setFiles] = useState([]); + + + {/* ... */} +; +``` + +### Single file (avatar) + +```tsx + + Upload avatar + +``` + +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 + { + 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)} +> + Upload + +``` + +## Parts + +| Part | Default Element | Description | +| ------------------------ | --------------- | ------------------------------------------------------------------- | +| `FileUpload.Root` | `
` | Owns the file state + hidden input, provides context | +| `FileUpload.Trigger` | `