diff --git a/docs/src/app/(docs)/concepts/theming/page.mdx b/docs/src/app/(docs)/concepts/theming/page.mdx
index 7537241a6c..ddb61b1d61 100644
--- a/docs/src/app/(docs)/concepts/theming/page.mdx
+++ b/docs/src/app/(docs)/concepts/theming/page.mdx
@@ -11,8 +11,20 @@ import * as d from "./demos";
# Theming
-Our prebuilt components are customizable so you can make them fit with the theme
-of your application.
+UploadThing ships with a default styled button and dropzone component that you
+can mount in your app if you don't have special needs on design. These default
+components are customizable, both in styling and content. The first parts of
+this doc will cover how to customize the default components and how you can make
+them fit with the theme of your application.
+
+Due to their nature, there are certain customizations you cannot make on the
+default components, which is why we also expose the unstyled,
+[headless primitives](#unstyled-primitive-components). These comes with behavior
+built-in but you have full control over what's rendered when and where.
+
+You can also build a fully custom flow you can opt for the
+[`useUploadThing`](/api-reference/react#use-upload-thing) hook that allows you
+to not only customize the look but also have full control of the behavior.
## UploadButton Anatomy
@@ -528,3 +540,92 @@ type UploadDropzoneProps = {
+
+
+
+# Unstyled Primitive Components
+
+These components allow you to bring your own styling solution while not having
+to implement any of the internals. They accept any normal HTML props and can be
+assigned specific HTML tags through the `as` prop.
+
+This is currently only implemented by `@uploadthing/react`.
+
+## Creating the unstyled components
+
+```ts {{ title: 'src/utils/uploadthing.ts' }}
+import { generateUploadPrimitives } from "@uploadthing/react";
+
+import type { OurFileRouter } from "~/server/uploadthing";
+
+export const UT = generateUploadPrimitives();
+```
+
+The returned `UT` object includes the following components:
+
+
+
+
+ This is the main provider that accept most of the same props as the default `
+ ` and ` ` accept.
+
+
+
+ The button element can be used to open the file selector. If you have auto mode
+ enabled, files are automatically uploaded once they are selected. For manual mode,
+ a second press on the button will upload the selected files.
+
+
+ A dropzone area which accepts files to be dropped. As for the button, you may have both
+ auto and manual mode.
+
+
+ A text field where you can display what types of files are allowed to be uploaded.
+
+
+ A button that clears the selected files.
+
+
+
+## Using Unstyled Components
+
+All components accept a children function which allows you to grab any piece of
+internal state. This includes `files`, `state`, `dropzone` state, and many more.
+
+A children function can also be passed as a prop if you prefer.
+
+### Example of Dropzone
+
+
+ The `dropzone` parameter will only be defined from within children of the
+ `Dropzone` component.
+
+
+```jsx
+
+
+ {({ state }) => (
+
+
Drag and drop
+
+ {state === "uploading" ? "Uploading" : "Upload file"}
+
+
+
+ )}
+
+
+```
+
+### Example of Button
+
+```jsx
+
+
+ {({ state }) => (state === "uploading" ? "Uploading" : "Upload file")}
+
+
+```
diff --git a/examples/minimal-appdir/src/app/page.tsx b/examples/minimal-appdir/src/app/page.tsx
index 6a6a87c6fb..4b2a02b0ba 100644
--- a/examples/minimal-appdir/src/app/page.tsx
+++ b/examples/minimal-appdir/src/app/page.tsx
@@ -4,6 +4,7 @@ import {
UploadButton,
UploadDropzone,
useUploadThing,
+ UT,
} from "~/utils/uploadthing";
export default function Home() {
@@ -70,6 +71,44 @@ export default function Home() {
await startUpload(files);
}}
/>
+ {
+ console.log(`onClientUploadComplete`, res);
+ alert("Upload Completed");
+ }}
+ onUploadBegin={() => {
+ console.log("upload begin");
+ }}
+ >
+
+ {({ dropzone, state }) => (
+
+
+ Drag and drop
+
+
+ {state === "uploading" ? "Uploading" : "Upload file"}
+
+
+
+ )}
+
+
);
}
diff --git a/examples/minimal-appdir/src/utils/uploadthing.ts b/examples/minimal-appdir/src/utils/uploadthing.ts
index cd9510b3df..66fc5f7ff4 100644
--- a/examples/minimal-appdir/src/utils/uploadthing.ts
+++ b/examples/minimal-appdir/src/utils/uploadthing.ts
@@ -2,11 +2,13 @@ import {
generateReactHelpers,
generateUploadButton,
generateUploadDropzone,
+ generateUploadPrimitives,
} from "@uploadthing/react";
import type { OurFileRouter } from "~/server/uploadthing";
export const UploadButton = generateUploadButton();
export const UploadDropzone = generateUploadDropzone();
+export const UT = generateUploadPrimitives();
export const { useUploadThing } = generateReactHelpers();
diff --git a/examples/minimal-pagedir/src/pages/index.tsx b/examples/minimal-pagedir/src/pages/index.tsx
index f3731bb2af..cee668fec8 100644
--- a/examples/minimal-pagedir/src/pages/index.tsx
+++ b/examples/minimal-pagedir/src/pages/index.tsx
@@ -2,6 +2,7 @@ import {
UploadButton,
UploadDropzone,
useUploadThing,
+ UT,
} from "~/utils/uploadthing";
export default function Home() {
@@ -54,6 +55,44 @@ export default function Home() {
await startUpload([file]);
}}
/>
+ {
+ console.log(`onClientUploadComplete`, res);
+ alert("Upload Completed");
+ }}
+ onUploadBegin={() => {
+ console.log("upload begin");
+ }}
+ >
+
+ {({ dropzone, state }) => (
+
+
+ Drag and drop
+
+
+ {state === "uploading" ? "Uploading" : "Upload file"}
+
+
+
+ )}
+
+
);
}
diff --git a/examples/minimal-pagedir/src/utils/uploadthing.ts b/examples/minimal-pagedir/src/utils/uploadthing.ts
index cd9510b3df..66fc5f7ff4 100644
--- a/examples/minimal-pagedir/src/utils/uploadthing.ts
+++ b/examples/minimal-pagedir/src/utils/uploadthing.ts
@@ -2,11 +2,13 @@ import {
generateReactHelpers,
generateUploadButton,
generateUploadDropzone,
+ generateUploadPrimitives,
} from "@uploadthing/react";
import type { OurFileRouter } from "~/server/uploadthing";
export const UploadButton = generateUploadButton();
export const UploadDropzone = generateUploadDropzone();
+export const UT = generateUploadPrimitives();
export const { useUploadThing } = generateReactHelpers();
diff --git a/examples/with-clerk-appdir/src/app/page.tsx b/examples/with-clerk-appdir/src/app/page.tsx
index 85561c0075..72010e36a7 100644
--- a/examples/with-clerk-appdir/src/app/page.tsx
+++ b/examples/with-clerk-appdir/src/app/page.tsx
@@ -2,7 +2,7 @@
import { SignIn, useAuth } from "@clerk/nextjs";
-import { UploadButton, UploadDropzone } from "~/utils/uploadthing";
+import { UploadButton, UploadDropzone, UT } from "~/utils/uploadthing";
export default function Home() {
const { isSignedIn } = useAuth();
@@ -35,6 +35,44 @@ export default function Home() {
console.log("upload begin");
}}
/>
+ {
+ console.log(`onClientUploadComplete`, res);
+ alert("Upload Completed");
+ }}
+ onUploadBegin={() => {
+ console.log("upload begin");
+ }}
+ >
+
+ {({ dropzone, state }) => (
+
+
+ Drag and drop
+
+
+ {state === "uploading" ? "Uploading" : "Upload file"}
+
+
+
+ )}
+
+
{!isSignedIn ? (
();
export const UploadDropzone = generateUploadDropzone
();
+export const UT = generateUploadPrimitives();
export const { useUploadThing } = generateReactHelpers();
diff --git a/examples/with-clerk-pagesdir/src/pages/index.tsx b/examples/with-clerk-pagesdir/src/pages/index.tsx
index 42521c201a..8d4e3e76d4 100644
--- a/examples/with-clerk-pagesdir/src/pages/index.tsx
+++ b/examples/with-clerk-pagesdir/src/pages/index.tsx
@@ -1,7 +1,7 @@
import { Inter } from "next/font/google";
import { SignIn, useAuth } from "@clerk/nextjs";
-import { UploadButton, UploadDropzone } from "~/utils/uploadthing";
+import { UploadButton, UploadDropzone, UT } from "~/utils/uploadthing";
const inter = Inter({ subsets: ["latin"] });
@@ -36,6 +36,44 @@ export default function Home() {
console.log("upload begin");
}}
/>
+ {
+ console.log(`onClientUploadComplete`, res);
+ alert("Upload Completed");
+ }}
+ onUploadBegin={() => {
+ console.log("upload begin");
+ }}
+ >
+
+ {({ dropzone, state }) => (
+
+
+ Drag and drop
+
+
+ {state === "uploading" ? "Uploading" : "Upload file"}
+
+
+
+ )}
+
+
{!isSignedIn ? (
();
export const UploadDropzone = generateUploadDropzone
();
+export const UT = generateUploadPrimitives();
export const { useUploadThing } = generateReactHelpers();
diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx
index c0ca05ec34..4e07cb2b5b 100644
--- a/packages/react/src/components/button.tsx
+++ b/packages/react/src/components/button.tsx
@@ -1,19 +1,12 @@
"use client";
import type { CSSProperties } from "react";
-import { useCallback, useMemo, useRef, useState } from "react";
import {
- allowedContentTextLabelGenerator,
contentFieldToContent,
defaultClassListMerger,
- generateMimeTypes,
- generatePermittedFileTypes,
- getFilesFromClipboardEvent,
- resolveMaybeUrlArg,
styleFieldToClassName,
styleFieldToCssObject,
- UploadAbortedError,
} from "@uploadthing/shared";
import type {
ContentField,
@@ -23,8 +16,7 @@ import type {
import type { FileRouter } from "uploadthing/types";
import type { UploadthingComponentProps } from "../types";
-import { __useUploadThingInternal } from "../use-uploadthing";
-import { usePaste } from "../utils/usePaste";
+import * as Primitive from "./primitive";
import { Cancel, Spinner } from "./shared";
type ButtonStyleFieldCallbackArgs = {
@@ -93,257 +85,133 @@ export function UploadButton<
) {
// Cast back to UploadthingComponentProps to get the correct type
// since the ErrorMessage messes it up otherwise
- const $props = props as unknown as UploadButtonProps &
- UploadThingInternalProps;
-
const {
- mode = "auto",
- appendOnPaste = false,
- cn = defaultClassListMerger,
- } = $props.config ?? {};
- const acRef = useRef(new AbortController());
-
- const fileInputRef = useRef(null);
- const [uploadProgress, setUploadProgress] = useState(
- $props.__internal_upload_progress ?? 0,
- );
- const [files, setFiles] = useState([]);
-
- const { startUpload, isUploading, routeConfig } = __useUploadThingInternal(
- resolveMaybeUrlArg($props.url),
- $props.endpoint,
- $props.fetch ?? globalThis.fetch,
- {
- signal: acRef.current.signal,
- headers: $props.headers,
- onClientUploadComplete: (res) => {
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
- setFiles([]);
- void $props.onClientUploadComplete?.(res);
- setUploadProgress(0);
- },
- uploadProgressGranularity: $props.uploadProgressGranularity,
- onUploadProgress: (p) => {
- setUploadProgress(p);
- $props.onUploadProgress?.(p);
- },
- onUploadError: $props.onUploadError,
- onUploadBegin: $props.onUploadBegin,
- onBeforeUploadBegin: $props.onBeforeUploadBegin,
- },
- );
- const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig);
-
- const disabled = !!($props.__internal_button_disabled ?? $props.disabled);
- const state = (() => {
- const ready = $props.__internal_state === "ready" || fileTypes.length > 0;
-
- if ($props.__internal_state) return $props.__internal_state;
- if (disabled) return "disabled";
- if (!ready) return "readying";
- if (!isUploading) return "ready";
- return "uploading";
- })();
-
- const uploadFiles = useCallback(
- (files: File[]) => {
- const input = "input" in $props ? $props.input : undefined;
- startUpload(files, input).catch((e) => {
- if (e instanceof UploadAbortedError) {
- void $props.onUploadAborted?.();
- } else {
- throw e;
- }
- });
- },
- [$props, startUpload],
- );
-
- const onUploadClick = (e: React.MouseEvent) => {
- if (state === "uploading") {
- e.preventDefault();
- e.stopPropagation();
-
- acRef.current.abort();
- acRef.current = new AbortController();
- return;
- }
- if (mode === "manual" && files.length > 0) {
- e.preventDefault();
- e.stopPropagation();
-
- uploadFiles(files);
- }
- };
-
- const inputProps = useMemo(
- () => ({
- type: "file",
- ref: fileInputRef,
- multiple,
- accept: generateMimeTypes(fileTypes).join(", "),
- onChange: (e: React.ChangeEvent) => {
- if (!e.target.files) return;
- const selectedFiles = Array.from(e.target.files);
-
- $props.onChange?.(selectedFiles);
-
- if (mode === "manual") {
- setFiles(selectedFiles);
- return;
- }
-
- uploadFiles(selectedFiles);
- },
- disabled,
- tabIndex: disabled ? -1 : 0,
- }),
- [$props, disabled, fileTypes, mode, multiple, uploadFiles],
- );
-
- usePaste((event) => {
- if (!appendOnPaste) return;
- if (document.activeElement !== fileInputRef.current) return;
-
- const pastedFiles = getFilesFromClipboardEvent(event);
- if (!pastedFiles) return;
-
- let filesToUpload = pastedFiles;
- setFiles((prev) => {
- filesToUpload = [...prev, ...pastedFiles];
-
- $props.onChange?.(filesToUpload);
+ className,
+ content,
+ appearance,
+ __internal_button_disabled,
+ ...rootProps
+ } = props as unknown as UploadButtonProps &
+ UploadThingInternalProps;
- return filesToUpload;
- });
+ const cn = rootProps.config?.cn ?? defaultClassListMerger;
- if (mode === "auto") uploadFiles(files);
- });
+ return (
+
+ {...(rootProps as any)}
+ disabled={__internal_button_disabled}
+ >
+ {({ state, uploadProgress, fileTypes, files, options }) => {
+ const styleFieldArg = {
+ ready: state !== "readying",
+ isUploading: state === "uploading",
+ uploadProgress: uploadProgress,
+ fileTypes: fileTypes,
+ files,
+ } as ButtonStyleFieldCallbackArgs;
+
+ const renderAllowedContent = () => (
+
+
+ {contentFieldToContent(content?.allowedContent, styleFieldArg)}
+
+
+ );
- const styleFieldArg = useMemo(
- () =>
- ({
- ready: state !== "readying",
- isUploading: state === "uploading",
- uploadProgress,
- fileTypes,
- files,
- }) as ButtonStyleFieldCallbackArgs,
- [fileTypes, files, state, uploadProgress],
- );
+ const renderClearButton = () => (
+
+ {contentFieldToContent(content?.clearBtn, styleFieldArg)}
+
+ );
- const renderButton = () => {
- const customContent = contentFieldToContent(
- $props.content?.button,
- styleFieldArg,
- );
- if (customContent) return customContent;
+ const renderButton = () => {
+ const customContent = contentFieldToContent(
+ content?.button,
+ styleFieldArg,
+ );
+ if (customContent) return customContent;
+
+ switch (state) {
+ case "readying": {
+ return "Loading...";
+ }
+ case "uploading": {
+ if (uploadProgress >= 100) return ;
+ return (
+
+
+ {uploadProgress}%
+
+
+
+ );
+ }
+ case "disabled":
+ case "ready":
+ default: {
+ if (options.mode === "manual" && files.length > 0) {
+ return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
+ }
+ return `Choose File${options.multiple ? `(s)` : ``}`;
+ }
+ }
+ };
- switch (state) {
- case "readying": {
- return "Loading...";
- }
- case "uploading": {
- if (uploadProgress >= 100) return ;
return (
-
-
- {Math.round(uploadProgress)}%
-
-
-
+
+
+ {renderButton()}
+
+ {options.mode === "manual" && files.length > 0
+ ? renderClearButton()
+ : renderAllowedContent()}
+
);
- }
- case "disabled":
- case "ready":
- default: {
- if (mode === "manual" && files.length > 0) {
- return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
- }
- return `Choose File${inputProps.multiple ? `(s)` : ``}`;
- }
- }
- };
-
- const renderClearButton = () => (
- {
- setFiles([]);
-
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
-
- $props.onChange?.([]);
}}
- className={cn(
- "h-[1.25rem] cursor-pointer rounded border-none bg-transparent text-gray-500 transition-colors hover:bg-slate-200 hover:text-gray-600",
- styleFieldToClassName($props.appearance?.clearBtn, styleFieldArg),
- )}
- style={styleFieldToCssObject($props.appearance?.clearBtn, styleFieldArg)}
- data-state={state}
- data-ut-element="clear-btn"
- >
- {contentFieldToContent($props.content?.clearBtn, styleFieldArg) ??
- "Clear"}
-
- );
-
- const renderAllowedContent = () => (
-
- {contentFieldToContent($props.content?.allowedContent, styleFieldArg) ??
- allowedContentTextLabelGenerator(routeConfig)}
-
- );
-
- return (
-
-
-
- {renderButton()}
-
- {mode === "manual" && files.length > 0
- ? renderClearButton()
- : renderAllowedContent()}
-
+
);
}
diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx
index 14a8b3bfe4..51cfea72c2 100644
--- a/packages/react/src/components/dropzone.tsx
+++ b/packages/react/src/components/dropzone.tsx
@@ -1,49 +1,15 @@
"use client";
-import {
- useCallback,
- useEffect,
- useMemo,
- useReducer,
- useRef,
- useState,
-} from "react";
-import type {
- ChangeEvent,
- CSSProperties,
- DragEvent,
- HTMLProps,
- KeyboardEvent,
- MouseEvent,
-} from "react";
-import { fromEvent } from "file-selector";
+import type { CSSProperties } from "react";
import {
- acceptPropAsAcceptAttr,
- allFilesAccepted,
- allowedContentTextLabelGenerator,
contentFieldToContent,
defaultClassListMerger,
- generateClientDropzoneAccept,
- generatePermittedFileTypes,
- getFilesFromClipboardEvent,
- initialState,
- isEnterOrSpace,
- isEventWithFiles,
- isFileAccepted,
- isIeOrEdge,
- isValidQuantity,
- isValidSize,
- noop,
- reducer,
- resolveMaybeUrlArg,
styleFieldToClassName,
styleFieldToCssObject,
- UploadAbortedError,
} from "@uploadthing/shared";
import type {
ContentField,
- DropzoneOptions,
ErrorMessage,
StyleField,
} from "@uploadthing/shared";
@@ -51,7 +17,7 @@ import type { FileRouter } from "uploadthing/types";
import type { UploadthingComponentProps } from "../types";
import { __useUploadThingInternal } from "../use-uploadthing";
-import { usePaste } from "../utils/usePaste";
+import * as Primitive from "./primitive";
import { Cancel, Spinner } from "./shared";
type DropzoneStyleFieldCallbackArgs = {
@@ -111,8 +77,6 @@ type UploadThingInternalProps = {
__internal_upload_progress?: number;
// Allow to set ready explicitly and independently of internal state
__internal_ready?: boolean;
- // Allow to show the button even if no files were added
- __internal_show_button?: boolean;
// Allow to disable the button
__internal_button_disabled?: boolean;
// Allow to disable the dropzone
@@ -129,599 +93,171 @@ export function UploadDropzone<
) {
// Cast back to UploadthingComponentProps to get the correct type
// since the ErrorMessage messes it up otherwise
- const $props = props as unknown as UploadDropzoneProps &
- UploadThingInternalProps;
-
const {
- mode = "manual",
- appendOnPaste = false,
- cn = defaultClassListMerger,
- } = $props.config ?? {};
- const acRef = useRef(new AbortController());
-
- const [files, setFiles] = useState([]);
- const [uploadProgress, setUploadProgress] = useState(
- $props.__internal_upload_progress ?? 0,
- );
-
- const { startUpload, isUploading, routeConfig } = __useUploadThingInternal(
- resolveMaybeUrlArg($props.url),
- $props.endpoint,
- $props.fetch ?? globalThis.fetch,
- {
- signal: acRef.current.signal,
- headers: $props.headers,
- onClientUploadComplete: (res) => {
- setFiles([]);
- void $props.onClientUploadComplete?.(res);
- setUploadProgress(0);
- },
- uploadProgressGranularity: $props.uploadProgressGranularity,
- onUploadProgress: (p) => {
- setUploadProgress(p);
- $props.onUploadProgress?.(p);
- },
- onUploadError: $props.onUploadError,
- onUploadBegin: $props.onUploadBegin,
- onBeforeUploadBegin: $props.onBeforeUploadBegin,
- },
- );
- const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig);
-
- const disabled = !!($props.__internal_dropzone_disabled ?? $props.disabled);
- const state = (() => {
- const ready =
- $props.__internal_ready ??
- ($props.__internal_state === "ready" || fileTypes.length > 0);
-
- if ($props.__internal_state) return $props.__internal_state;
- if (disabled) return "disabled";
- if (!ready) return "readying";
- if (!isUploading) return "ready";
- return "uploading";
- })();
-
- const uploadFiles = useCallback(
- (files: File[]) => {
- const input = "input" in $props ? $props.input : undefined;
- startUpload(files, input).catch((e) => {
- if (e instanceof UploadAbortedError) {
- void $props.onUploadAborted?.();
- } else {
- throw e;
- }
- });
- },
- [$props, startUpload],
- );
-
- const onUploadClick = (e: React.MouseEvent) => {
- if (state === "uploading") {
- e.preventDefault();
- e.stopPropagation();
-
- acRef.current.abort();
- acRef.current = new AbortController();
- return;
- }
- if (mode === "manual" && files.length > 0) {
- e.preventDefault();
- e.stopPropagation();
-
- uploadFiles(files);
- }
- };
-
- const onDrop = useCallback(
- (acceptedFiles: File[]) => {
- $props.onDrop?.(acceptedFiles);
- $props.onChange?.(acceptedFiles);
-
- setFiles(acceptedFiles);
-
- // If mode is auto, start upload immediately
- if (mode === "auto") uploadFiles(acceptedFiles);
- },
- [$props, mode, uploadFiles],
- );
-
- const { getRootProps, getInputProps, isDragActive, rootRef } = useDropzone({
+ className,
+ content,
+ appearance,
onDrop,
- multiple,
- accept: generateClientDropzoneAccept(fileTypes),
- disabled,
- });
-
- usePaste((event: ClipboardEvent) => {
- if (!appendOnPaste) return;
- if (document.activeElement !== rootRef.current) return;
-
- const pastedFiles = getFilesFromClipboardEvent(event);
- if (!pastedFiles?.length) return;
-
- let filesToUpload = pastedFiles;
- setFiles((prev) => {
- filesToUpload = [...prev, ...pastedFiles];
-
- $props.onChange?.(filesToUpload);
-
- return filesToUpload;
- });
-
- $props.onChange?.(filesToUpload);
-
- if (mode === "auto") uploadFiles(filesToUpload);
- });
-
- const styleFieldArg = useMemo(
- () =>
- ({
- ready: state !== "readying",
- isUploading: state === "uploading",
- uploadProgress,
- fileTypes,
- files,
- isDragActive,
- }) as DropzoneStyleFieldCallbackArgs,
- [fileTypes, files, state, uploadProgress, isDragActive],
- );
-
- const getUploadButtonContents = () => {
- const customContent = contentFieldToContent(
- $props.content?.button,
- styleFieldArg,
- );
- if (customContent) return customContent;
+ __internal_dropzone_disabled,
+ __internal_button_disabled,
+ ...rootProps
+ } = props as unknown as UploadDropzoneProps &
+ UploadThingInternalProps;
- switch (state) {
- case "readying": {
- return "Loading...";
- }
- case "uploading": {
- if (uploadProgress >= 100) return ;
- return (
-
-
- {Math.round(uploadProgress)}%
-
-
-
- );
- }
- case "disabled":
- case "ready":
- default: {
- if (mode === "manual" && files.length > 0) {
- return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
- }
- return `Choose File${multiple ? `(s)` : ``}`;
- }
- }
- };
+ const cn = rootProps.config?.cn ?? defaultClassListMerger;
return (
-
- {contentFieldToContent($props.content?.uploadIcon, styleFieldArg) ?? (
-
-
-
- )}
-
-
- {contentFieldToContent($props.content?.label, styleFieldArg) ??
- (state === "ready"
- ? `Choose ${multiple ? "file(s)" : "a file"} or drag and drop`
- : `Loading...`)}
-
-
{...(rootProps as any)}>
+
- {contentFieldToContent($props.content?.allowedContent, styleFieldArg) ??
- allowedContentTextLabelGenerator(routeConfig)}
-
-
-
- {getUploadButtonContents()}
-
-
- );
-}
-
-export type DropEvent =
- | Event
- | React.DragEvent
- | React.ChangeEvent;
-
-/**
- * A React hook that creates a drag 'n' drop area.
- *
- * ### Example
- *
- * ```tsx
- * function MyDropzone() {
- * const { getRootProps, getInputProps } = useDropzone({
- * onDrop: acceptedFiles => {
- * // do something with the File objects, e.g. upload to some server
- * }
- * });
- *
- * return (
- *
- *
- *
Drag and drop some files here, or click to select files
- *
- * )
- * }
- * ```
- */
-export function useDropzone({
- accept,
- disabled = false,
- maxSize = Number.POSITIVE_INFINITY,
- minSize = 0,
- multiple = true,
- maxFiles = 0,
- onDrop,
-}: DropzoneOptions) {
- const acceptAttr = useMemo(() => acceptPropAsAcceptAttr(accept), [accept]);
-
- const rootRef = useRef(null);
- const inputRef = useRef(null);
- const dragTargetsRef = useRef([]);
-
- const [state, dispatch] = useReducer(reducer, initialState);
-
- useEffect(() => {
- // Update file dialog active state when the window is focused on
- const onWindowFocus = () => {
- // Execute the timeout only if the file dialog is opened in the browser
- if (state.isFileDialogActive) {
- setTimeout(() => {
- if (inputRef.current) {
- const { files } = inputRef.current;
-
- if (!files?.length) {
- dispatch({ type: "closeDialog" });
+ {({ files, fileTypes, dropzone, uploadProgress, state, options }) => {
+ const styleFieldArg = {
+ fileTypes,
+ isDragActive: !!dropzone?.isDragActive,
+ isUploading: state === "uploading",
+ ready: state !== "readying",
+ uploadProgress,
+ files,
+ } as DropzoneStyleFieldCallbackArgs;
+
+ const getUploadButtonContents = () => {
+ const customContent = contentFieldToContent(
+ content?.button,
+ styleFieldArg,
+ );
+ if (customContent) return customContent;
+
+ switch (state) {
+ case "readying": {
+ return "Loading...";
+ }
+ case "uploading": {
+ if (uploadProgress >= 100) return ;
+ return (
+
+
+ {uploadProgress}%
+
+
+
+ );
+ }
+ case "disabled":
+ case "ready":
+ default: {
+ if (options.mode === "manual" && files.length > 0) {
+ return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
+ }
+ return `Choose File${options.multiple ? `(s)` : ``}`;
+ }
}
- }
- }, 300);
- }
- };
-
- const controller = new AbortController();
- window.addEventListener("focus", onWindowFocus, {
- signal: controller.signal,
- });
- return () => {
- controller.abort();
- };
- }, [state.isFileDialogActive]);
-
- useEffect(() => {
- const onDocumentDrop = (event: DropEvent) => {
- // If we intercepted an event for our instance, let it propagate down to the instance's onDrop handler
- if (rootRef.current?.contains(event.target as Node)) return;
-
- event.preventDefault();
- dragTargetsRef.current = [];
- };
- const onDocumentDragOver = (e: Pick) =>
- e.preventDefault();
- const controller = new AbortController();
-
- document.addEventListener("dragover", onDocumentDragOver, {
- capture: false,
- signal: controller.signal,
- });
- document.addEventListener("drop", onDocumentDrop, {
- capture: false,
- signal: controller.signal,
- });
-
- return () => {
- controller.abort();
- };
- }, []);
-
- const onDragEnter = useCallback(
- (event: DragEvent) => {
- event.preventDefault();
- event.persist();
-
- dragTargetsRef.current = [...dragTargetsRef.current, event.target];
-
- if (isEventWithFiles(event)) {
- Promise.resolve(fromEvent(event))
- .then((files) => {
- if (event.isPropagationStopped()) return;
-
- const fileCount = files.length;
- const isDragAccept =
- fileCount > 0 &&
- allFilesAccepted({
- files: files as File[],
- accept: acceptAttr!,
- minSize,
- maxSize,
- multiple,
- maxFiles,
- });
- const isDragReject = fileCount > 0 && !isDragAccept;
-
- dispatch({
- type: "setDraggedFiles",
- payload: {
- isDragAccept,
- isDragReject,
- isDragActive: true,
- },
- });
- })
- .catch(noop);
- }
- },
- [acceptAttr, maxFiles, maxSize, minSize, multiple],
+ };
+
+ return (
+
+ {contentFieldToContent(content?.uploadIcon, styleFieldArg) ?? (
+
+
+
+ )}
+
+
+ {contentFieldToContent(content?.label, styleFieldArg) ??
+ (state === "ready"
+ ? `Choose ${options.multiple ? "file(s)" : "a file"} or drag and drop`
+ : `Loading...`)}
+
+
+
+ {contentFieldToContent(content?.allowedContent, styleFieldArg)}
+
+
+
+ {getUploadButtonContents()}
+
+
+ );
+ }}
+
+
);
-
- const onDragOver = useCallback((event: DragEvent) => {
- event.preventDefault();
- event.persist();
-
- const hasFiles = isEventWithFiles(event);
- if (hasFiles) {
- try {
- event.dataTransfer.dropEffect = "copy";
- } catch {
- noop();
- }
- }
-
- return false;
- }, []);
-
- const onDragLeave = useCallback((event: DragEvent) => {
- event.preventDefault();
- event.persist();
-
- // Only deactivate once the dropzone and all children have been left
- const targets = dragTargetsRef.current.filter((target) =>
- rootRef.current?.contains(target as Node),
- );
-
- // Make sure to remove a target present multiple times only once
- // (Firefox may fire dragenter/dragleave multiple times on the same element)
- const targetIdx = targets.indexOf(event.target);
- if (targetIdx !== -1) targets.splice(targetIdx, 1);
- dragTargetsRef.current = targets;
- if (targets.length > 0) return;
-
- dispatch({
- type: "setDraggedFiles",
- payload: {
- isDragActive: false,
- isDragAccept: false,
- isDragReject: false,
- },
- });
- }, []);
-
- const setFiles = useCallback(
- (files: File[]) => {
- const acceptedFiles: File[] = [];
-
- files.forEach((file) => {
- const accepted = isFileAccepted(file, acceptAttr!);
- const sizeMatch = isValidSize(file, minSize, maxSize);
-
- if (accepted && sizeMatch) {
- acceptedFiles.push(file);
- }
- });
-
- if (!isValidQuantity(acceptedFiles, multiple, maxFiles)) {
- acceptedFiles.splice(0);
- }
-
- dispatch({
- type: "setFiles",
- payload: {
- acceptedFiles,
- },
- });
-
- onDrop(acceptedFiles);
- },
- [acceptAttr, maxFiles, maxSize, minSize, multiple, onDrop],
- );
-
- const onDropCb = useCallback(
- (event: ChangeEvent) => {
- event.preventDefault();
- event.persist();
-
- dragTargetsRef.current = [];
-
- if (isEventWithFiles(event)) {
- Promise.resolve(fromEvent(event))
- .then((files) => {
- if (event.isPropagationStopped()) return;
- setFiles(files as File[]);
- })
- .catch(noop);
- }
- dispatch({ type: "reset" });
- },
- [setFiles],
- );
-
- const openFileDialog = useCallback(() => {
- if (inputRef.current) {
- dispatch({ type: "openDialog" });
- inputRef.current.value = "";
- inputRef.current.click();
- }
- }, []);
-
- // Cb to open the file dialog when SPACE/ENTER occurs on the dropzone
- const onKeyDown = useCallback(
- (event: KeyboardEvent) => {
- // Ignore keyboard events bubbling up the DOM tree
- if (!rootRef.current?.isEqualNode(event.target as Node)) return;
-
- if (isEnterOrSpace(event)) {
- event.preventDefault();
- openFileDialog();
- }
- },
- [openFileDialog],
- );
-
- const onInputElementClick = useCallback(
- (e: MouseEvent) => {
- e.stopPropagation();
- if (state.isFileDialogActive) {
- e.preventDefault();
- }
- },
- [state.isFileDialogActive],
- );
-
- // Update focus state for the dropzone
- const onFocus = useCallback(() => dispatch({ type: "focus" }), []);
- const onBlur = useCallback(() => dispatch({ type: "blur" }), []);
-
- const onClick = useCallback(() => {
- // In IE11/Edge the file-browser dialog is blocking, therefore,
- // use setTimeout() to ensure React can handle state changes
- if (isIeOrEdge()) setTimeout(openFileDialog, 0);
- else openFileDialog();
- }, [openFileDialog]);
-
- const getRootProps = useMemo(
- () => (): HTMLProps => ({
- ref: rootRef,
- role: "presentation",
- ...(!disabled
- ? {
- tabIndex: 0,
- onKeyDown,
- onFocus,
- onBlur,
- onClick,
- onDragEnter,
- onDragOver,
- onDragLeave,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- onDrop: onDropCb as any,
- }
- : {}),
- }),
- [
- disabled,
- onBlur,
- onClick,
- onDragEnter,
- onDragLeave,
- onDragOver,
- onDropCb,
- onFocus,
- onKeyDown,
- ],
- );
-
- const getInputProps = useMemo(
- () => (): HTMLProps => ({
- ref: inputRef,
- type: "file",
- style: { display: "none" },
- accept: acceptAttr,
- multiple,
- tabIndex: -1,
- ...(!disabled
- ? {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- onChange: onDropCb as any,
- onClick: onInputElementClick,
- }
- : {}),
- }),
- [acceptAttr, multiple, onDropCb, onInputElementClick, disabled],
- );
-
- return {
- ...state,
- getRootProps,
- getInputProps,
- rootRef,
- };
}
diff --git a/packages/react/src/components/index.tsx b/packages/react/src/components/index.tsx
index c9c0626d27..0d2caf202f 100644
--- a/packages/react/src/components/index.tsx
+++ b/packages/react/src/components/index.tsx
@@ -14,6 +14,8 @@ import type { UploadButtonProps } from "./button";
import { UploadButton } from "./button";
import type { UploadDropzoneProps } from "./dropzone";
import { UploadDropzone } from "./dropzone";
+import * as primitives from "./primitive";
+import type { RootPrimitiveComponentProps } from "./primitive/root";
import { Uploader } from "./uploader";
export { UploadButton, UploadDropzone, Uploader };
@@ -72,6 +74,26 @@ export const generateUploadDropzone = (
return TypedDropzone;
};
+export const generateUploadPrimitives = (
+ opts?: GenerateTypedHelpersOptions,
+) => {
+ warnIfInvalidPeerDependency(
+ "@uploadthing/react",
+ peerDependencies.uploadthing,
+ uploadthingClientVersion,
+ );
+
+ const url = resolveMaybeUrlArg(opts?.url);
+
+ const TypedUploadRoot = (
+ props: Omit<
+ RootPrimitiveComponentProps,
+ keyof GenerateTypedHelpersOptions
+ >,
+ ) => {...(props as any)} url={url} />;
+ return { ...primitives, Root: TypedUploadRoot };
+};
+
export const generateUploader = (
opts?: GenerateTypedHelpersOptions,
) => {
diff --git a/packages/react/src/components/primitive/allowed-content.tsx b/packages/react/src/components/primitive/allowed-content.tsx
new file mode 100644
index 0000000000..d093c49c33
--- /dev/null
+++ b/packages/react/src/components/primitive/allowed-content.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import type { ElementType, Ref } from "react";
+
+import { allowedContentTextLabelGenerator } from "@uploadthing/shared";
+
+import { forwardRefWithAs } from "../../utils/forwardRefWithAs";
+import { PrimitiveSlot, usePrimitiveValues } from "./root";
+import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root";
+
+const DEFAULT_ALLOWED_CONTENT_TAG = "div";
+
+export type PrimitiveAllowedContentProps<
+ TTag extends ElementType = typeof DEFAULT_ALLOWED_CONTENT_TAG,
+> = PrimitiveComponentProps;
+
+export function AllowedContentFn<
+ TTag extends ElementType = typeof DEFAULT_ALLOWED_CONTENT_TAG,
+>(
+ { children, as, ...props }: PrimitiveAllowedContentProps,
+ ref: Ref,
+) {
+ const { routeConfig, state } = usePrimitiveValues("AllowedContent");
+
+ const Comp = as ?? DEFAULT_ALLOWED_CONTENT_TAG;
+
+ return (
+
+
+
+ );
+}
+
+type _internal_ComponentAllowedContent = HasDisplayName &
+ ((
+ props: PrimitiveAllowedContentProps &
+ RefProp,
+ ) => JSX.Element);
+
+export const AllowedContent = forwardRefWithAs(
+ AllowedContentFn,
+) as _internal_ComponentAllowedContent;
diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx
new file mode 100644
index 0000000000..33390fa748
--- /dev/null
+++ b/packages/react/src/components/primitive/button.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import type { ElementType, Ref } from "react";
+
+import { forwardRefWithAs } from "../../utils/forwardRefWithAs";
+import { PrimitiveSlot, usePrimitiveValues } from "./root";
+import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root";
+
+const DEFAULT_BUTTON_TAG = "label";
+
+export type PrimitiveButtonProps<
+ TTag extends ElementType = typeof DEFAULT_BUTTON_TAG,
+> = PrimitiveComponentProps;
+
+function ButtonFn(
+ { children, onClick, as, ...props }: PrimitiveButtonProps,
+ ref: Ref,
+) {
+ const {
+ refs,
+ setFiles,
+ dropzone,
+ accept,
+ state,
+ files,
+ abortUpload,
+ options,
+ uploadFiles,
+ } = usePrimitiveValues("Button");
+
+ const Comp = as ?? DEFAULT_BUTTON_TAG;
+
+ return (
+ {
+ if (state === "disabled") return;
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ onClick?.(e);
+ if (state === "uploading") {
+ e.preventDefault();
+ e.stopPropagation();
+ abortUpload();
+ return;
+ }
+ if (options.mode === "manual" && files.length > 0) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ uploadFiles();
+ }
+ }}
+ ref={ref}
+ >
+ {children}
+ {!dropzone && (
+ {
+ if (!e.target.files) return;
+ setFiles(Array.from(e.target.files));
+ }}
+ disabled={state === "disabled"}
+ tabIndex={state === "disabled" ? -1 : 0}
+ className="sr-only"
+ />
+ )}
+
+ );
+}
+
+type _internal_ComponentButton = HasDisplayName &
+ ((
+ props: PrimitiveButtonProps & RefProp,
+ ) => JSX.Element);
+
+export const Button = forwardRefWithAs(ButtonFn) as _internal_ComponentButton;
diff --git a/packages/react/src/components/primitive/clear-button.tsx b/packages/react/src/components/primitive/clear-button.tsx
new file mode 100644
index 0000000000..eda755cb93
--- /dev/null
+++ b/packages/react/src/components/primitive/clear-button.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import type { ElementType, Ref } from "react";
+
+import { forwardRefWithAs } from "../../utils/forwardRefWithAs";
+import { PrimitiveSlot, usePrimitiveValues } from "./root";
+import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root";
+
+const DEFAULT_CLEAR_BUTTON_TAG = "label";
+
+export type PrimitiveClearButtonProps<
+ TTag extends ElementType = typeof DEFAULT_CLEAR_BUTTON_TAG,
+> = PrimitiveComponentProps;
+
+function ClearButtonFn<
+ TTag extends ElementType = typeof DEFAULT_CLEAR_BUTTON_TAG,
+>(
+ { children, onClick, as, ...props }: PrimitiveClearButtonProps,
+ ref: Ref,
+) {
+ const { setFiles, state } = usePrimitiveValues("ClearButton");
+ const Comp = as ?? DEFAULT_CLEAR_BUTTON_TAG;
+
+ return (
+ {
+ if (state === "disabled") return;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ onClick?.(e);
+ setFiles([]);
+ }}
+ ref={ref}
+ >
+
+
+ );
+}
+
+type _internal_ComponentClearButton = HasDisplayName &
+ ((
+ props: PrimitiveClearButtonProps & RefProp,
+ ) => JSX.Element);
+
+export const ClearButton = forwardRefWithAs(
+ ClearButtonFn,
+) as _internal_ComponentClearButton;
diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx
new file mode 100644
index 0000000000..03bfb14654
--- /dev/null
+++ b/packages/react/src/components/primitive/dropzone.tsx
@@ -0,0 +1,434 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
+import type {
+ ChangeEvent,
+ DragEvent,
+ ElementType,
+ HTMLProps,
+ KeyboardEvent,
+ MouseEvent,
+ Ref,
+} from "react";
+import { fromEvent } from "file-selector";
+
+import {
+ acceptPropAsAcceptAttr,
+ allFilesAccepted,
+ generateClientDropzoneAccept,
+ initialState,
+ isEnterOrSpace,
+ isEventWithFiles,
+ isFileAccepted,
+ isIeOrEdge,
+ isValidQuantity,
+ isValidSize,
+ noop,
+ reducer,
+} from "@uploadthing/shared";
+import type { DropzoneOptions } from "@uploadthing/shared";
+
+import { forwardRefWithAs } from "../../utils/forwardRefWithAs";
+import {
+ PrimitiveContextMergeProvider,
+ PrimitiveSlot,
+ usePrimitiveValues,
+} from "./root";
+import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root";
+
+const DEFAULT_DROPZONE_TAG = "div";
+
+export type PrimitiveDropzoneProps<
+ TTag extends ElementType = typeof DEFAULT_DROPZONE_TAG,
+> = PrimitiveComponentProps & {
+ disabled?: boolean | undefined;
+ /**
+ * Callback called when files are dropped.
+ *
+ * @param acceptedFiles - The files that were accepted.
+ * @deprecated Use `onFilesChange` in ``
+ */
+ onFilesDropped?: ((acceptedFiles: File[]) => void) | undefined;
+};
+
+function DropzoneFn(
+ {
+ children,
+ as,
+ onDrop,
+ onChange,
+ disabled: componentDisabled,
+ ...props
+ }: PrimitiveDropzoneProps,
+ ref: Ref,
+) {
+ const { setFiles, options, fileTypes, state, refs } =
+ usePrimitiveValues("Dropzone");
+
+ const onDropCallback = useCallback(
+ (acceptedFiles: File[]) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ onDrop?.(acceptedFiles);
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ onChange?.(acceptedFiles);
+
+ setFiles(acceptedFiles);
+ },
+ [setFiles, onDrop, onChange],
+ );
+
+ const { getRootProps, getInputProps, isDragActive, rootRef } = useDropzone({
+ onDrop: onDropCallback,
+ multiple: options.multiple,
+ accept: generateClientDropzoneAccept(fileTypes),
+ disabled: state === "disabled" || componentDisabled,
+ });
+
+ const Comp = as ?? DEFAULT_DROPZONE_TAG;
+
+ refs.focusElementRef = rootRef;
+
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+type _internal_ComponentDropzone = HasDisplayName &
+ ((
+ props: PrimitiveDropzoneProps & RefProp,
+ ) => JSX.Element);
+
+export const Dropzone = forwardRefWithAs(
+ DropzoneFn,
+) as _internal_ComponentDropzone;
+
+export type DropEvent =
+ | Event
+ | React.DragEvent
+ | React.ChangeEvent;
+
+/**
+ * A React hook that creates a drag 'n' drop area.
+ *
+ * ### Example
+ *
+ * ```tsx
+ * function MyDropzone() {
+ * const { getRootProps, getInputProps } = useDropzone({
+ * onDrop: acceptedFiles => {
+ * // do something with the File objects, e.g. upload to some server
+ * }
+ * });
+ *
+ * return (
+ *
+ *
+ *
Drag and drop some files here, or click to select files
+ *
+ * )
+ * }
+ * ```
+ */
+export function useDropzone({
+ accept,
+ disabled = false,
+ maxSize = Number.POSITIVE_INFINITY,
+ minSize = 0,
+ multiple = true,
+ maxFiles = 0,
+ onDrop,
+}: DropzoneOptions) {
+ const acceptAttr = useMemo(() => acceptPropAsAcceptAttr(accept), [accept]);
+
+ const rootRef = useRef(null);
+ const inputRef = useRef(null);
+ const dragTargetsRef = useRef([]);
+
+ const [state, dispatch] = useReducer(reducer, initialState);
+
+ useEffect(() => {
+ // Update file dialog active state when the window is focused on
+ const onWindowFocus = () => {
+ // Execute the timeout only if the file dialog is opened in the browser
+ if (state.isFileDialogActive) {
+ setTimeout(() => {
+ if (inputRef.current) {
+ const { files } = inputRef.current;
+
+ if (!files?.length) {
+ dispatch({ type: "closeDialog" });
+ }
+ }
+ }, 300);
+ }
+ };
+
+ window.addEventListener("focus", onWindowFocus, false);
+ return () => {
+ window.removeEventListener("focus", onWindowFocus, false);
+ };
+ }, [state.isFileDialogActive]);
+
+ useEffect(() => {
+ const onDocumentDrop = (event: DropEvent) => {
+ // If we intercepted an event for our instance, let it propagate down to the instance's onDrop handler
+ if (rootRef.current?.contains(event.target as Node)) return;
+
+ event.preventDefault();
+ dragTargetsRef.current = [];
+ };
+ const onDocumentDragOver = (e: Pick) =>
+ e.preventDefault();
+
+ document.addEventListener("dragover", onDocumentDragOver, false);
+ document.addEventListener("drop", onDocumentDrop, false);
+
+ return () => {
+ document.removeEventListener("dragover", onDocumentDragOver);
+ document.removeEventListener("drop", onDocumentDrop);
+ };
+ }, []);
+
+ const onDragEnter = useCallback(
+ (event: DragEvent) => {
+ event.preventDefault();
+ event.persist();
+
+ dragTargetsRef.current = [...dragTargetsRef.current, event.target];
+
+ if (isEventWithFiles(event)) {
+ Promise.resolve(fromEvent(event))
+ .then((files) => {
+ if (event.isPropagationStopped()) return;
+
+ const fileCount = files.length;
+ const isDragAccept =
+ fileCount > 0 &&
+ allFilesAccepted({
+ files: files as File[],
+ accept: acceptAttr!,
+ minSize,
+ maxSize,
+ multiple,
+ maxFiles,
+ });
+ const isDragReject = fileCount > 0 && !isDragAccept;
+
+ dispatch({
+ type: "setDraggedFiles",
+ payload: {
+ isDragAccept,
+ isDragReject,
+ isDragActive: true,
+ },
+ });
+ })
+ .catch(noop);
+ }
+ },
+ [acceptAttr, maxFiles, maxSize, minSize, multiple],
+ );
+
+ const onDragOver = useCallback((event: DragEvent) => {
+ event.preventDefault();
+ event.persist();
+
+ const hasFiles = isEventWithFiles(event);
+ if (hasFiles) {
+ try {
+ event.dataTransfer.dropEffect = "copy";
+ } catch {
+ noop();
+ }
+ }
+
+ return false;
+ }, []);
+
+ const onDragLeave = useCallback((event: DragEvent) => {
+ event.preventDefault();
+ event.persist();
+
+ // Only deactivate once the dropzone and all children have been left
+ const targets = dragTargetsRef.current.filter((target) =>
+ rootRef.current?.contains(target as Node),
+ );
+
+ // Make sure to remove a target present multiple times only once
+ // (Firefox may fire dragenter/dragleave multiple times on the same element)
+ const targetIdx = targets.indexOf(event.target);
+ if (targetIdx !== -1) targets.splice(targetIdx, 1);
+ dragTargetsRef.current = targets;
+ if (targets.length > 0) return;
+
+ dispatch({
+ type: "setDraggedFiles",
+ payload: {
+ isDragActive: false,
+ isDragAccept: false,
+ isDragReject: false,
+ },
+ });
+ }, []);
+
+ const setFiles = useCallback(
+ (files: File[]) => {
+ const acceptedFiles: File[] = [];
+
+ files.forEach((file) => {
+ const accepted = isFileAccepted(file, acceptAttr!);
+ const sizeMatch = isValidSize(file, minSize, maxSize);
+
+ if (accepted && sizeMatch) {
+ acceptedFiles.push(file);
+ }
+ });
+
+ if (!isValidQuantity(acceptedFiles, multiple, maxFiles)) {
+ acceptedFiles.splice(0);
+ }
+
+ dispatch({
+ type: "setFiles",
+ payload: {
+ acceptedFiles,
+ },
+ });
+
+ onDrop(acceptedFiles);
+ },
+ [acceptAttr, maxFiles, maxSize, minSize, multiple, onDrop],
+ );
+
+ const onDropCb = useCallback(
+ (event: ChangeEvent) => {
+ event.preventDefault();
+ event.persist();
+
+ dragTargetsRef.current = [];
+
+ if (isEventWithFiles(event)) {
+ Promise.resolve(fromEvent(event))
+ .then((files) => {
+ if (event.isPropagationStopped()) return;
+ setFiles(files as File[]);
+ })
+ .catch(noop);
+ }
+ dispatch({ type: "reset" });
+ },
+ [setFiles],
+ );
+
+ const openFileDialog = useCallback(() => {
+ if (inputRef.current) {
+ dispatch({ type: "openDialog" });
+ inputRef.current.value = "";
+ inputRef.current.click();
+ }
+ }, []);
+
+ // Cb to open the file dialog when SPACE/ENTER occurs on the dropzone
+ const onKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ // Ignore keyboard events bubbling up the DOM tree
+ if (!rootRef.current?.isEqualNode(event.target as Node)) return;
+
+ if (isEnterOrSpace(event)) {
+ event.preventDefault();
+ openFileDialog();
+ }
+ },
+ [openFileDialog],
+ );
+
+ const onInputElementClick = useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation();
+ if (state.isFileDialogActive) {
+ e.preventDefault();
+ }
+ },
+ [state.isFileDialogActive],
+ );
+
+ // Update focus state for the dropzone
+ const onFocus = useCallback(() => dispatch({ type: "focus" }), []);
+ const onBlur = useCallback(() => dispatch({ type: "blur" }), []);
+
+ const onClick = useCallback(() => {
+ // In IE11/Edge the file-browser dialog is blocking, therefore,
+ // use setTimeout() to ensure React can handle state changes
+ if (isIeOrEdge()) setTimeout(openFileDialog, 0);
+ else openFileDialog();
+ }, [openFileDialog]);
+
+ const getRootProps = useMemo(
+ () => (): HTMLProps => ({
+ ref: rootRef,
+ role: "presentation",
+ ...(!disabled
+ ? {
+ tabIndex: 0,
+ onKeyDown,
+ onFocus,
+ onBlur,
+ onClick,
+ onDragEnter,
+ onDragOver,
+ onDragLeave,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ onDrop: onDropCb as any,
+ }
+ : {}),
+ }),
+ [
+ disabled,
+ onBlur,
+ onClick,
+ onDragEnter,
+ onDragLeave,
+ onDragOver,
+ onDropCb,
+ onFocus,
+ onKeyDown,
+ ],
+ );
+
+ const getInputProps = useMemo(
+ () => (): HTMLProps => ({
+ ref: inputRef,
+ type: "file",
+ style: { display: "none" },
+ accept: acceptAttr,
+ multiple,
+ tabIndex: -1,
+ ...(!disabled
+ ? {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ onChange: onDropCb as any,
+ onClick: onInputElementClick,
+ }
+ : {}),
+ }),
+ [acceptAttr, multiple, onDropCb, onInputElementClick, disabled],
+ );
+
+ return {
+ ...state,
+ getRootProps,
+ getInputProps,
+ rootRef,
+ };
+}
diff --git a/packages/react/src/components/primitive/index.tsx b/packages/react/src/components/primitive/index.tsx
new file mode 100644
index 0000000000..aee829a210
--- /dev/null
+++ b/packages/react/src/components/primitive/index.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+export { Root } from "./root";
+export { Button } from "./button";
+export { Dropzone } from "./dropzone";
+export { AllowedContent } from "./allowed-content";
+export { ClearButton } from "./clear-button";
diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx
new file mode 100644
index 0000000000..8d7c50f7be
--- /dev/null
+++ b/packages/react/src/components/primitive/root.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type { ElementType, ProviderProps, Ref, RefObject } from "react";
+
+import {
+ generateMimeTypes,
+ generatePermittedFileTypes,
+ getFilesFromClipboardEvent,
+ resolveMaybeUrlArg,
+ UploadAbortedError,
+} from "@uploadthing/shared";
+import type {
+ ExpandedRouteConfig,
+ FileRouterInputKey,
+} from "@uploadthing/shared";
+import type { FileRouter } from "uploadthing/types";
+
+import type { UploadthingComponentProps } from "../../types";
+import { __useUploadThingInternal } from "../../use-uploadthing";
+import { useControllableState } from "../../utils/useControllableState";
+import { usePaste } from "../../utils/usePaste";
+
+type PrimitiveContextValues = {
+ state: "readying" | "ready" | "uploading" | "disabled";
+
+ files: File[];
+ fileTypes: FileRouterInputKey[];
+ accept: string;
+
+ /**
+ * @remarks If the mode is set to 'auto' this function will upload the files too
+ */
+ setFiles: (_: File[]) => void;
+
+ /**
+ * Uploads the selected files
+ * @remarks If the mode is set to 'auto', there is no need to call this function
+ */
+ uploadFiles: () => void;
+
+ abortUpload: () => void;
+
+ routeConfig: ExpandedRouteConfig | undefined;
+
+ uploadProgress: number;
+
+ options: {
+ mode: "auto" | "manual";
+ multiple: boolean;
+ };
+
+ refs: {
+ focusElementRef: RefObject;
+ fileInputRef: RefObject;
+ };
+
+ /**
+ * @remarks This will be only defined when nested in a
+ */
+ dropzone?: {
+ isDragActive: boolean;
+ };
+};
+
+const PrimitiveContext = createContext(null);
+
+export function PrimitiveContextMergeProvider({
+ value,
+ ...props
+}: ProviderProps>) {
+ const currentValue = useContext(PrimitiveContext);
+
+ if (currentValue === null) {
+ throw new Error(
+ " must be used within a ",
+ );
+ }
+
+ return (
+
+ );
+}
+
+export function usePrimitiveValues(componentName?: string) {
+ const values = useContext(PrimitiveContext);
+ if (values === null) {
+ const name = componentName ? `` : "usePrimitiveValues";
+ throw new Error(`${name} must be used within a `);
+ }
+ return values;
+}
+
+export function PrimitiveSlot({
+ children,
+ componentName,
+ default: defaultChildren,
+}: {
+ children: PrimitiveComponentChildren;
+ componentName?: string;
+ default?: React.ReactNode;
+}) {
+ const values = usePrimitiveValues(componentName);
+ return typeof children === "function"
+ ? children(values)
+ : (children ?? defaultChildren);
+}
+
+export type HasDisplayName = {
+ displayName: string;
+};
+
+export type RefProp any> = T extends (
+ props: any,
+ ref: Ref,
+) => any
+ ? { ref?: Ref }
+ : never;
+
+export type PrimitiveComponentProps = Omit<
+ React.ComponentPropsWithoutRef,
+ "children"
+> &
+ PrimitiveComponentChildrenProp & {
+ as?: TTag;
+ };
+
+export type PrimitiveComponentChildrenProp = {
+ children?: PrimitiveComponentChildren;
+};
+
+export type PrimitiveComponentChildren =
+ | ((values: PrimitiveContextValues) => React.ReactNode)
+ | React.ReactNode;
+
+/** These are some internal stuff we use to test the component and for forcing a state in docs */
+type UploadThingInternalProps = {
+ __internal_state?: "readying" | "ready" | "uploading";
+ // Allow to set upload progress for testing
+ __internal_upload_progress?: number;
+};
+
+export type RootPrimitiveComponentProps<
+ TRouter extends FileRouter,
+ TEndpoint extends keyof TRouter,
+> = UploadthingComponentProps & {
+ // TODO: add @see comment for docs
+ children?: PrimitiveComponentChildren;
+ files?: File[];
+ onFilesChange?: (_: File[]) => void;
+};
+
+export function Root<
+ TRouter extends FileRouter,
+ TEndpoint extends keyof TRouter,
+>(
+ props: RootPrimitiveComponentProps &
+ UploadThingInternalProps,
+) {
+ const fileRouteInput = "input" in props ? props.input : undefined;
+
+ const { mode = "auto", appendOnPaste = false } = props.config ?? {};
+ const acRef = useRef(new AbortController());
+
+ const focusElementRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const [uploadProgress, setUploadProgress] = useState(
+ props.__internal_upload_progress ?? 0,
+ );
+ const [files, setFiles] = useControllableState({
+ prop: props.files,
+ onChange: props.onFilesChange,
+ defaultProp: [],
+ });
+
+ const { startUpload, isUploading, routeConfig } = __useUploadThingInternal(
+ resolveMaybeUrlArg(props.url),
+ props.endpoint,
+ props.fetch ?? globalThis.fetch,
+ {
+ signal: acRef.current.signal,
+ headers: props.headers,
+ onClientUploadComplete: (res) => {
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ setFiles([]);
+ void props.onClientUploadComplete?.(res);
+ setUploadProgress(0);
+ },
+ onUploadProgress: (p) => {
+ setUploadProgress(p);
+ props.onUploadProgress?.(p);
+ },
+ onUploadError: props.onUploadError,
+ onUploadBegin: props.onUploadBegin,
+ onBeforeUploadBegin: props.onBeforeUploadBegin,
+ },
+ );
+
+ const { onUploadAborted } = props;
+ const uploadFiles = useCallback(
+ (files: File[]) => {
+ startUpload(files, fileRouteInput).catch((e) => {
+ if (e instanceof UploadAbortedError) {
+ void onUploadAborted?.();
+ } else {
+ throw e;
+ }
+ });
+ },
+ [onUploadAborted, startUpload, fileRouteInput],
+ );
+
+ const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig);
+
+ const accept = generateMimeTypes(fileTypes).join(", ");
+
+ const state = (() => {
+ const ready = props.__internal_state === "ready" || fileTypes.length > 0;
+
+ if (props.__internal_state) return props.__internal_state;
+ if (fileTypes.length === 0 || !!props.disabled) return "disabled";
+ if (!ready) return "readying";
+ if (!isUploading) return "ready";
+ return "uploading";
+ })();
+
+ usePaste((event) => {
+ if (!appendOnPaste) return;
+ const ref = focusElementRef.current ?? fileInputRef.current;
+
+ if (document.activeElement !== ref) return;
+
+ const pastedFiles = getFilesFromClipboardEvent(event);
+ if (!pastedFiles) return;
+
+ let filesToUpload = pastedFiles;
+ setFiles((prev) => {
+ filesToUpload = [...prev, ...pastedFiles];
+
+ props.onChange?.(filesToUpload);
+
+ return filesToUpload;
+ });
+
+ if (mode === "auto") void uploadFiles(filesToUpload);
+ });
+
+ const primitiveValues = useMemo(
+ () => ({
+ files,
+ setFiles: (files) => {
+ setFiles(files);
+ props.onChange?.(files);
+
+ if (files.length <= 0) {
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ return;
+ }
+ if (mode === "manual") return;
+
+ void uploadFiles(files);
+ },
+ uploadFiles: () => void uploadFiles(files),
+ abortUpload: () => {
+ acRef.current.abort();
+ acRef.current = new AbortController();
+ },
+ uploadProgress,
+ state,
+ accept,
+ fileTypes,
+ options: { mode, multiple },
+ refs: {
+ focusElementRef,
+ fileInputRef,
+ },
+ routeConfig,
+ }),
+ [
+ props,
+ files,
+ setFiles,
+ uploadFiles,
+ uploadProgress,
+ state,
+ accept,
+ fileTypes,
+ mode,
+ multiple,
+ routeConfig,
+ ],
+ );
+
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index e479cb47ba..72e146632e 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,7 @@ export {
Uploader,
generateUploadButton,
generateUploadDropzone,
+ generateUploadPrimitives,
generateUploader,
} from "./components";
@@ -11,5 +12,5 @@ export { generateReactHelpers } from "./use-uploadthing";
export type * from "./types";
-export { useDropzone } from "./components/dropzone";
+export { useDropzone } from "./components/primitive/dropzone";
export type * from "./components/dropzone";
diff --git a/packages/react/src/utils/forwardRefWithAs.ts b/packages/react/src/utils/forwardRefWithAs.ts
new file mode 100644
index 0000000000..f8115983c6
--- /dev/null
+++ b/packages/react/src/utils/forwardRefWithAs.ts
@@ -0,0 +1,16 @@
+"use client";
+
+import { forwardRef } from "react";
+
+/**
+ * This is a hack, but basically we want to keep the full 'API' of the component, but we do want to
+ * wrap it in a forwardRef so that we _can_ passthrough the ref
+ * @see https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/utils/render.ts#L431
+ */
+export function forwardRefWithAs<
+ T extends { name: string; displayName?: string },
+>(component: T): T & { displayName: string } {
+ return Object.assign(forwardRef(component as any) as any, {
+ displayName: component.displayName ?? component.name,
+ });
+}
diff --git a/packages/react/src/utils/useControllableState.ts b/packages/react/src/utils/useControllableState.ts
new file mode 100644
index 0000000000..2894bbcda8
--- /dev/null
+++ b/packages/react/src/utils/useControllableState.ts
@@ -0,0 +1,83 @@
+import * as React from "react";
+
+/**
+ * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
+ * prop or avoid re-executing effects when passed as a dependency
+ * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
+ */
+function useCallbackRef unknown>(
+ callback: T | undefined,
+): T {
+ const callbackRef = React.useRef(callback);
+
+ React.useEffect(() => {
+ callbackRef.current = callback;
+ });
+
+ // https://github.com/facebook/react/issues/19240
+ return React.useMemo(
+ () => ((...args) => callbackRef.current?.(...args)) as T,
+ [],
+ );
+}
+
+type UseControllableStateParams = {
+ prop?: T | undefined;
+ defaultProp: NoInfer;
+ onChange?: ((state: T) => void) | undefined;
+};
+
+type SetStateFn = (prevState?: T) => T;
+
+const useUncontrolledState = ({
+ defaultProp,
+ onChange,
+}: Omit, "prop">) => {
+ const uncontrolledState = React.useState(defaultProp);
+ const [value] = uncontrolledState;
+ const prevValueRef = React.useRef(value);
+ const handleChange = useCallbackRef(onChange);
+
+ React.useEffect(() => {
+ if (value === undefined) return;
+ if (prevValueRef.current !== value) {
+ handleChange(value);
+ prevValueRef.current = value;
+ }
+ }, [value, prevValueRef, handleChange]);
+
+ return uncontrolledState;
+};
+
+/**
+ * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
+ */
+export const useControllableState = ({
+ prop,
+ defaultProp,
+ onChange,
+}: UseControllableStateParams) => {
+ const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
+ defaultProp,
+ onChange,
+ });
+ const isControlled = prop !== undefined;
+ const value = isControlled ? prop : uncontrolledProp;
+ const handleChange = useCallbackRef(onChange);
+
+ const setValue: React.Dispatch> = React.useCallback(
+ (nextValue) => {
+ if (isControlled) {
+ const setter = nextValue as SetStateFn;
+ const value =
+ typeof nextValue === "function" ? setter(prop) : nextValue;
+ if (value !== prop) handleChange(value);
+ } else {
+ setUncontrolledProp(nextValue);
+ }
+ },
+ [isControlled, prop, setUncontrolledProp, handleChange],
+ );
+
+ return [value, setValue] as const;
+};
diff --git a/playground/app/layout.tsx b/playground/app/layout.tsx
index dacdfddebe..c5d60b0461 100644
--- a/playground/app/layout.tsx
+++ b/playground/app/layout.tsx
@@ -1,4 +1,5 @@
import { Suspense } from "react";
+import Link from "next/link";
import { connection } from "next/server";
import { Schema } from "effect";
@@ -44,24 +45,32 @@ function Nav() {
);
return (
-
-
-
-
{".".repeat(100)}
+ <>
+
+
+
+ {".".repeat(100)}
+
+
+ Sign In
+
- Sign In
-
- }
- >
-
-
+ }
+ >
+
+
-
- AppId: {token.appId} Region: {token.regions[0]}
-
-
+
+ AppId: {token.appId} Region: {token.regions[0]}
+
+
+
+ Home
+ Primitives
+
+ >
);
}
diff --git a/playground/app/primitives/page.tsx b/playground/app/primitives/page.tsx
new file mode 100644
index 0000000000..da36c08c70
--- /dev/null
+++ b/playground/app/primitives/page.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { Button } from "../../components/button";
+import { Label } from "../../components/fieldset";
+import { Code, Subheading } from "../../components/text";
+import { UT } from "../../lib/uploadthing";
+
+export function CustomUploader() {
+ return (
+ rr.anything}
+ input={{}}
+ onClientUploadComplete={(res) => {
+ console.log(`onClientUploadComplete`, res);
+ alert("Upload Completed");
+ }}
+ onUploadBegin={() => {
+ console.log("upload begin");
+ }}
+ config={{
+ appendOnPaste: true,
+ }}
+ >
+ Custom Uploader
+
+ {({ dropzone, state, uploadProgress }) => (
+
+ Drag and drop
+
+ {state === "uploading" ? "Uploading" : "Upload file"}
+ {!!uploadProgress && ` ${uploadProgress}%`}
+
+
+
+ )}
+
+
+ );
+}
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/playground/components/text.tsx b/playground/components/text.tsx
new file mode 100644
index 0000000000..7d83ebc92d
--- /dev/null
+++ b/playground/components/text.tsx
@@ -0,0 +1,83 @@
+import Link from "next/link";
+import cx from "clsx";
+
+type HeadingProps = {
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
+} & React.ComponentProps<"h1" | "h2" | "h3" | "h4" | "h5" | "h6">;
+
+export function Heading({ className, level = 1, ...props }: HeadingProps) {
+ const Element: `h${typeof level}` = `h${level}`;
+
+ return (
+
+ );
+}
+
+export function Subheading({ className, level = 2, ...props }: HeadingProps) {
+ const Element: `h${typeof level}` = `h${level}`;
+
+ return (
+
+ );
+}
+
+export function Text({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+
+ );
+}
+
+export function TextLink({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export function Strong({
+ className,
+ ...props
+}: React.ComponentProps<"strong">) {
+ return (
+
+ );
+}
+
+export function Code({ className, ...props }: React.ComponentProps<"code">) {
+ return (
+
+ );
+}
diff --git a/playground/components/uploader.tsx b/playground/components/uploader.tsx
index c13d7b9ca0..cae5d147ad 100644
--- a/playground/components/uploader.tsx
+++ b/playground/components/uploader.tsx
@@ -3,15 +3,10 @@
import { useActionState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
-import { generateReactHelpers, generateUploadButton } from "@uploadthing/react";
-
-import { UploadRouter } from "../app/api/uploadthing/route";
import { uploadFiles } from "../lib/actions";
+import { UTButton } from "../lib/uploadthing";
import { Input, Label } from "./fieldset";
-export const UTButton = generateUploadButton();
-export const { useUploadThing } = generateReactHelpers();
-
function ServerUploader(props: { type: "file" | "url" }) {
const formRef = useRef(null);
const [state, dispatch, isUploading] = useActionState(uploadFiles, {
@@ -82,8 +77,6 @@ export function Uploader() {
className="ut-button:bg-red-600"
/>
-
-
);
}
diff --git a/playground/lib/uploadthing.ts b/playground/lib/uploadthing.ts
new file mode 100644
index 0000000000..b9bd534cea
--- /dev/null
+++ b/playground/lib/uploadthing.ts
@@ -0,0 +1,11 @@
+import {
+ generateReactHelpers,
+ generateUploadButton,
+ generateUploadPrimitives,
+} from "@uploadthing/react";
+
+import { UploadRouter } from "../app/api/uploadthing/route";
+
+export const UT = generateUploadPrimitives();
+export const UTButton = generateUploadButton();
+export const { useUploadThing } = generateReactHelpers();