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 = () => ( - - ); - - const renderAllowedContent = () => ( -
- {contentFieldToContent($props.content?.allowedContent, styleFieldArg) ?? - allowedContentTextLabelGenerator(routeConfig)} -
- ); - - return ( -
- - {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) ?? ( - - - - )} - -
{...(rootProps as any)}> + - {contentFieldToContent($props.content?.allowedContent, styleFieldArg) ?? - allowedContentTextLabelGenerator(routeConfig)} -
- - -
- ); -} - -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?.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 ( -
- } - > - - + } + > + + - - AppId: {token.appId} Region: {token.regions[0]} - - + + AppId: {token.appId} Region: {token.regions[0]} + + + + ); } 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, + }} + > + + + {({ 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();