From ad67fd0a224e4476a9199fb277a5ab3858040fa4 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Sat, 14 Sep 2024 18:58:41 +0100 Subject: [PATCH 01/39] feat: primitive components --- examples/minimal-appdir/next-env.d.ts | 2 +- examples/minimal-appdir/src/app/page.tsx | 14 + .../minimal-appdir/src/utils/uploadthing.ts | 2 + examples/minimal-pagedir/src/pages/index.tsx | 14 + .../minimal-pagedir/src/utils/uploadthing.ts | 2 + examples/with-clerk-appdir/src/app/page.tsx | 15 +- .../src/utils/uploadthing.ts | 2 + .../with-clerk-pagesdir/src/pages/index.tsx | 15 +- .../src/utils/uploadthing.ts | 2 + packages/react/src/components/index.tsx | 16 + .../components/primitive/allowed-content.tsx | 18 ++ .../react/src/components/primitive/button.tsx | 65 ++++ .../src/components/primitive/dropzone.tsx | 40 +++ .../react/src/components/primitive/index.tsx | 4 + .../react/src/components/primitive/root.tsx | 302 ++++++++++++++++++ packages/react/src/index.ts | 1 + 16 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 packages/react/src/components/primitive/allowed-content.tsx create mode 100644 packages/react/src/components/primitive/button.tsx create mode 100644 packages/react/src/components/primitive/dropzone.tsx create mode 100644 packages/react/src/components/primitive/index.tsx create mode 100644 packages/react/src/components/primitive/root.tsx diff --git a/examples/minimal-appdir/next-env.d.ts b/examples/minimal-appdir/next-env.d.ts index 4f11a03dc6..40c3d68096 100644 --- a/examples/minimal-appdir/next-env.d.ts +++ b/examples/minimal-appdir/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/examples/minimal-appdir/src/app/page.tsx b/examples/minimal-appdir/src/app/page.tsx index ba9d1d9935..67f12092dd 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,19 @@ export default function Home() { await startUpload(files); }} /> + + + {({ dropzone, isUploading }) => ( + <> + {isUploading ? "Uploading" : "Upload file"} +
+ +
+ {dropzone?.isDragActive && Dragging} + + )} +
+
); } 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 1a23e5be03..56fa754ca0 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,19 @@ export default function Home() { await startUpload([file]); }} /> + + + {({ dropzone, isUploading }) => ( + <> + {isUploading ? "Uploading" : "Upload file"} +
+ +
+ {dropzone?.isDragActive && Dragging} + + )} +
+
); } 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 db89eb94c3..5e180ff18f 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,19 @@ export default function Home() { console.log("upload begin"); }} /> + + + {({ dropzone, isUploading }) => ( + <> + {isUploading ? "Uploading" : "Upload file"} +
+ +
+ {dropzone?.isDragActive && Dragging} + + )} +
+
{!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 6d9cada39a..c1bc2f8440 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,19 @@ export default function Home() { console.log("upload begin"); }} /> + + + {({ dropzone, isUploading }) => ( + <> + {isUploading ? "Uploading" : "Upload file"} +
+ +
+ {dropzone?.isDragActive && Dragging} + + )} +
+
{!isSignedIn ? (
(); export const UploadDropzone = generateUploadDropzone(); +export const UT = generateUploadPrimitives(); export const { useUploadThing } = generateReactHelpers(); diff --git a/packages/react/src/components/index.tsx b/packages/react/src/components/index.tsx index 4d1d1948c9..ebc3621fad 100644 --- a/packages/react/src/components/index.tsx +++ b/packages/react/src/components/index.tsx @@ -9,6 +9,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 { RootPrimitiveComponentProps } from "./primitive/root"; import { Uploader } from "./uploader"; export { UploadButton, UploadDropzone, Uploader }; @@ -41,6 +43,20 @@ export const generateUploadDropzone = ( return TypedDropzone; }; +export const generateUploadPrimitives = ( + opts?: GenerateTypedHelpersOptions, +) => { + 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..ce71a17a93 --- /dev/null +++ b/packages/react/src/components/primitive/allowed-content.tsx @@ -0,0 +1,18 @@ +import { allowedContentTextLabelGenerator } from "@uploadthing/shared"; + +import { + PrimitiveComponentChildrenProp, + PrimitiveSlot, + usePrimitiveValues, +} from "./root"; + +export function AllowedContent(props: PrimitiveComponentChildrenProp) { + const { routeConfig } = usePrimitiveValues("AllowedContent"); + + return ( + + ); +} diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx new file mode 100644 index 0000000000..181d0a11fa --- /dev/null +++ b/packages/react/src/components/primitive/button.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { + PrimitiveComponentProps, + PrimitiveSlot, + usePrimitiveValues, +} from "./root"; + +export function Button({ + children, + onClick, + ...props +}: PrimitiveComponentProps<"label">) { + const { + refs, + disabled, + setFiles, + dropzone, + accept, + state, + files, + abortUpload, + options, + uploadFiles, + } = usePrimitiveValues("Button"); + + return ( + + ); +} diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx new file mode 100644 index 0000000000..91be71c1a3 --- /dev/null +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -0,0 +1,40 @@ +import { useDropzone } from "@uploadthing/dropzone/react"; +import { generateClientDropzoneAccept } from "@uploadthing/shared"; + +import { + PrimitiveComponentProps, + PrimitiveContextMergeProvider, + PrimitiveSlot, + usePrimitiveValues, +} from "./root"; + +export function Dropzone({ + children, + ...props +}: PrimitiveComponentProps<"div">) { + const { setFiles, options, fileTypes, disabled, state, refs } = + usePrimitiveValues("Dropzone"); + + const { getRootProps, getInputProps, isDragActive, rootRef } = useDropzone({ + onDrop: setFiles, + multiple: options.multiple, + accept: fileTypes ? generateClientDropzoneAccept(fileTypes) : undefined, + disabled, + }); + + refs.focusElementRef = rootRef; + + return ( + +
+ {children} + +
+
+ ); +} diff --git a/packages/react/src/components/primitive/index.tsx b/packages/react/src/components/primitive/index.tsx new file mode 100644 index 0000000000..942f522077 --- /dev/null +++ b/packages/react/src/components/primitive/index.tsx @@ -0,0 +1,4 @@ +export { Root } from "./root"; +export { Button } from "./button"; +export { Dropzone } from "./dropzone"; +export { AllowedContent } from "./allowed-content"; diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx new file mode 100644 index 0000000000..49f1b2f6d6 --- /dev/null +++ b/packages/react/src/components/primitive/root.tsx @@ -0,0 +1,302 @@ +import { + createContext, + ElementType, + ProviderProps, + RefObject, + useCallback, + useContext, + useRef, + useState, +} from "react"; + +import { + generateMimeTypes, + generatePermittedFileTypes, + getFilesFromClipboardEvent, + resolveMaybeUrlArg, + UploadAbortedError, +} from "@uploadthing/shared"; +import type { + ErrorMessage, + ExpandedRouteConfig, + FileRouterInputKey, +} from "@uploadthing/shared"; +import type { FileRouter } from "uploadthing/types"; + +import type { UploadthingComponentProps } from "../../types"; +import { INTERNAL_uploadthingHookGen } from "../../useUploadThing"; +import { usePaste } from "../../utils/usePaste"; + +type PrimitiveContextValues = { + state: "readying" | "ready" | "uploading" | "disabled"; + + disabled: boolean; + isUploading: boolean; + ready: boolean; + + 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 usePrimitiveChildren( + children: PrimitiveComponentChildren, + componentName: string, +) { + return typeof children === "function" + ? children?.(usePrimitiveValues(componentName)) + : children; +} + +export function PrimitiveSlot({ + children, + componentName, + default: defaultChildren, +}: { + children: PrimitiveComponentChildren; + componentName?: string; + default?: React.ReactNode; +}) { + if (!children) return defaultChildren; + return typeof children === "function" + ? children?.(usePrimitiveValues(componentName)) + : children; +} + +export type PrimitiveComponentProps = Omit< + React.ComponentPropsWithRef, + "children" +> & + PrimitiveComponentChildrenProp; + +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"; + __internal_upload_progress?: number; + __internal_button_disabled?: boolean; +}; + +export type RootPrimitiveComponentProps< + TRouter extends FileRouter, + TEndpoint extends keyof TRouter, +> = UploadthingComponentProps & { + // TODO: add @see comment for docs + children?: PrimitiveComponentChildren; +}; + +export function Root< + TRouter extends FileRouter, + TEndpoint extends keyof TRouter, +>( + props: FileRouter extends TRouter + ? ErrorMessage<"You forgot to pass the generic"> + : RootPrimitiveComponentProps, +) { + // Cast back to UploadthingComponentProps to get the correct type + // since the ErrorMessage messes it up otherwise + const $props = props as unknown as RootPrimitiveComponentProps< + TRouter, + TEndpoint + > & + UploadThingInternalProps; + + const fileRouteInput = "input" in $props ? $props.input : undefined; + + const { mode = "auto", appendOnPaste = false } = $props.config ?? {}; + const acRef = useRef(new AbortController()); + + const useUploadThing = INTERNAL_uploadthingHookGen({ + url: resolveMaybeUrlArg($props.url), + }); + + const focusElementRef = useRef(null); + const fileInputRef = useRef(null); + const [uploadProgress, setUploadProgress] = useState( + $props.__internal_upload_progress ?? 0, + ); + const [files, setFiles] = useState([]); + + const { startUpload, isUploading, routeConfig } = useUploadThing( + $props.endpoint, + { + 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 uploadFiles = useCallback( + (files: File[]) => { + startUpload(files, fileRouteInput).catch((e) => { + if (e instanceof UploadAbortedError) { + void $props.onUploadAborted?.(); + } else { + throw e; + } + }); + }, + [$props, startUpload, fileRouteInput], + ); + + const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); + + let disabled = fileTypes.length === 0; + if ($props.disabled) disabled = true; + if ($props.__internal_button_disabled) disabled = true; + + const accept = generateMimeTypes(fileTypes).join(", "); + + const state = (() => { + if ($props.__internal_state) return $props.__internal_state; + if (disabled) return "disabled"; + if (!disabled && !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(files); + }); + + const primitiveValues: PrimitiveContextValues = { + files, + setFiles: (files) => { + setFiles(files); + $props.onChange?.(files); + + if (mode === "manual") { + setFiles(files); + return; + } + + void uploadFiles(files); + }, + uploadFiles: () => void uploadFiles(files), + abortUpload: () => { + acRef.current.abort(); + acRef.current = new AbortController(); + }, + uploadProgress, + state, + disabled, + accept, + fileTypes, + options: { mode, multiple }, + refs: { + focusElementRef, + fileInputRef, + }, + routeConfig, + isUploading: state === "uploading", + ready: state === "ready", + }; + + return ( + + {$props.children} + + ); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7e03a865eb..fe62611e2a 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"; From bd7475fd075a92e29b49c6c4c10822e4a7e48524 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Sat, 14 Sep 2024 20:58:22 +0100 Subject: [PATCH 02/39] feat: controllable files in primitive components --- .../react/src/components/primitive/root.tsx | 9 +- .../react/src/utils/useControllableState.ts | 83 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/utils/useControllableState.ts diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 49f1b2f6d6..73786d0857 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -25,6 +25,7 @@ import type { FileRouter } from "uploadthing/types"; import type { UploadthingComponentProps } from "../../types"; import { INTERNAL_uploadthingHookGen } from "../../useUploadThing"; +import { useControllableState } from "../../utils/useControllableState"; import { usePaste } from "../../utils/usePaste"; type PrimitiveContextValues = { @@ -155,6 +156,8 @@ export type RootPrimitiveComponentProps< > = UploadthingComponentProps & { // TODO: add @see comment for docs children?: PrimitiveComponentChildren; + files?: File[]; + onFilesChange?: (_: File[]) => void; }; export function Root< @@ -187,7 +190,11 @@ export function Root< const [uploadProgress, setUploadProgress] = useState( $props.__internal_upload_progress ?? 0, ); - const [files, setFiles] = useState([]); + const [files, setFiles] = useControllableState({ + prop: $props.files, + onChange: $props.onFilesChange, + defaultProp: [], + }); const { startUpload, isUploading, routeConfig } = useUploadThing( $props.endpoint, diff --git a/packages/react/src/utils/useControllableState.ts b/packages/react/src/utils/useControllableState.ts new file mode 100644 index 0000000000..e18100a330 --- /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) 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; +}; From a184147536f1a0b3bb2421f2c2b59897f4535f35 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:29:20 +0100 Subject: [PATCH 03/39] refactor: remove dead code --- packages/react/src/components/primitive/root.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 73786d0857..5ec1c74169 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -105,15 +105,6 @@ export function usePrimitiveValues(componentName?: string) { return values; } -export function usePrimitiveChildren( - children: PrimitiveComponentChildren, - componentName: string, -) { - return typeof children === "function" - ? children?.(usePrimitiveValues(componentName)) - : children; -} - export function PrimitiveSlot({ children, componentName, From b1c57d9f643cd9ff585a2f578e2b7a1c9c70b0d8 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:38:23 +0100 Subject: [PATCH 04/39] feat: primitive clear button --- .../src/components/primitive/clear-button.tsx | 28 +++++++++++++++++++ .../react/src/components/primitive/index.tsx | 1 + .../react/src/components/primitive/root.tsx | 5 ++-- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 packages/react/src/components/primitive/clear-button.tsx 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..945f49f6f0 --- /dev/null +++ b/packages/react/src/components/primitive/clear-button.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { + PrimitiveComponentProps, + PrimitiveSlot, + usePrimitiveValues, +} from "./root"; + +export function ClearButton({ + children, + onClick, + ...props +}: PrimitiveComponentProps<"label">) { + const { setFiles, state } = usePrimitiveValues("Button"); + + return ( + + ); +} diff --git a/packages/react/src/components/primitive/index.tsx b/packages/react/src/components/primitive/index.tsx index 942f522077..0a3bce8c91 100644 --- a/packages/react/src/components/primitive/index.tsx +++ b/packages/react/src/components/primitive/index.tsx @@ -2,3 +2,4 @@ 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 index 5ec1c74169..d9deda6b05 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -265,10 +265,11 @@ export function Root< setFiles(files); $props.onChange?.(files); - if (mode === "manual") { - setFiles(files); + if (files.length <= 0) { + if (fileInputRef.current) fileInputRef.current.value = ""; return; } + if (mode === "manual") return; void uploadFiles(files); }, From 90ab08fc051a05cf99f9e8592191cb1e95f5b293 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Fri, 20 Sep 2024 21:12:29 +0100 Subject: [PATCH 05/39] refactor: button component to use primitives --- packages/react/src/components/button.tsx | 358 ++++++------------ .../react/src/components/primitive/root.tsx | 2 +- 2 files changed, 108 insertions(+), 252 deletions(-) diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx index 9d0e3e0b2f..941410b0ab 100644 --- a/packages/react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -1,18 +1,10 @@ "use client"; -import { useCallback, useMemo, useRef, useState } from "react"; - import { - allowedContentTextLabelGenerator, contentFieldToContent, defaultClassListMerger, - generateMimeTypes, - generatePermittedFileTypes, - getFilesFromClipboardEvent, - resolveMaybeUrlArg, styleFieldToClassName, styleFieldToCssObject, - UploadAbortedError, } from "@uploadthing/shared"; import type { ContentField, @@ -22,8 +14,7 @@ import type { import type { FileRouter } from "uploadthing/types"; import type { UploadthingComponentProps } from "../types"; -import { INTERNAL_uploadthingHookGen } from "../useUploadThing"; -import { usePaste } from "../utils/usePaste"; +import * as Primitive from "./primitive"; import { Cancel, progressWidths, Spinner } from "./shared"; type ButtonStyleFieldCallbackArgs = { @@ -65,13 +56,6 @@ export type UploadButtonProps< content?: ButtonContent; }; -/** 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"; - __internal_upload_progress?: number; - __internal_button_disabled?: boolean; -}; - /** * @remarks It is not recommended using this directly as it requires manually binding generics. Instead, use `createUploadButton`. * @example @@ -91,247 +75,119 @@ 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 fileRouteInput = "input" in $props ? $props.input : undefined; - - const { - mode = "auto", - appendOnPaste = false, - cn = defaultClassListMerger, - } = $props.config ?? {}; - const acRef = useRef(new AbortController()); - - const useUploadThing = INTERNAL_uploadthingHookGen({ - url: resolveMaybeUrlArg($props.url), - }); - - const fileInputRef = useRef(null); - const labelRef = useRef(null); - const [uploadProgress, setUploadProgress] = useState( - $props.__internal_upload_progress ?? 0, - ); - const [files, setFiles] = useState([]); - - const { startUpload, isUploading, routeConfig } = useUploadThing( - $props.endpoint, - { - 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 uploadFiles = useCallback( - (files: File[]) => { - startUpload(files, fileRouteInput).catch((e) => { - if (e instanceof UploadAbortedError) { - void $props.onUploadAborted?.(); - } else { - throw e; - } - }); - }, - [$props, startUpload, fileRouteInput], - ); - - const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); - - 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; - } - - void uploadFiles(selectedFiles); - }, - disabled: fileTypes.length === 0, - tabIndex: fileTypes.length === 0 ? -1 : 0, - }), - [$props, fileTypes, mode, multiple, uploadFiles], - ); - - if ($props.__internal_button_disabled) inputProps.disabled = true; - if ($props.disabled) inputProps.disabled = true; - - const state = (() => { - if ($props.__internal_state) return $props.__internal_state; - if (inputProps.disabled) return "disabled"; - if (!inputProps.disabled && !isUploading) return "ready"; - return "uploading"; - })(); - - usePaste((event) => { - if (!appendOnPaste) return; - if (document.activeElement !== fileInputRef.current) return; + const { className, content, appearance, ...$props } = + props as unknown as UploadButtonProps; - const pastedFiles = getFilesFromClipboardEvent(event); - if (!pastedFiles) return; + const cn = defaultClassListMerger ?? $props.config ?? {}; - let filesToUpload = pastedFiles; - setFiles((prev) => { - filesToUpload = [...prev, ...pastedFiles]; - - $props.onChange?.(filesToUpload); - - return filesToUpload; - }); - - if (mode === "auto") void uploadFiles(files); - }); + return ( + {...($props as any)}> + {({ state, uploadProgress, fileTypes, files, options }) => { + const styleFieldArg = { + ready: state !== "readying", + isUploading: state === "uploading", + uploadProgress: uploadProgress, + fileTypes: fileTypes, + } as ButtonStyleFieldCallbackArgs; + + const renderAllowedContent = () => ( +
+ + {contentFieldToContent(content?.allowedContent, styleFieldArg)} + +
+ ); - const styleFieldArg = { - ready: state !== "readying", - isUploading: state === "uploading", - uploadProgress, - fileTypes, - } as ButtonStyleFieldCallbackArgs; + 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 ( - - {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/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index d9deda6b05..1f7058f003 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -99,7 +99,7 @@ export function PrimitiveContextMergeProvider({ export function usePrimitiveValues(componentName?: string) { const values = useContext(PrimitiveContext); if (values === null) { - const name = componentName ? "usePrimitiveValues" : ``; + const name = componentName ? `` : "usePrimitiveValues"; throw new Error(`${name} must be used within a `); } return values; From 1eb1b3130893bba4653e106dcbf0a7c1764dd02d Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 19:51:50 +0100 Subject: [PATCH 06/39] feat: wrap primitive allowed content in a div --- .../components/primitive/allowed-content.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/primitive/allowed-content.tsx b/packages/react/src/components/primitive/allowed-content.tsx index ce71a17a93..a692b6c470 100644 --- a/packages/react/src/components/primitive/allowed-content.tsx +++ b/packages/react/src/components/primitive/allowed-content.tsx @@ -1,18 +1,23 @@ import { allowedContentTextLabelGenerator } from "@uploadthing/shared"; import { - PrimitiveComponentChildrenProp, + PrimitiveComponentProps, PrimitiveSlot, usePrimitiveValues, } from "./root"; -export function AllowedContent(props: PrimitiveComponentChildrenProp) { - const { routeConfig } = usePrimitiveValues("AllowedContent"); +export function AllowedContent({ + children, + ...props +}: PrimitiveComponentProps<"div">) { + const { routeConfig, state } = usePrimitiveValues("AllowedContent"); return ( - +
+ +
); } From f2d191d032018cce27cbde6d9fddaefdaba15b9c Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:23:13 +0100 Subject: [PATCH 07/39] feat: `as` prop & ref support in primitive components --- .../components/primitive/allowed-content.tsx | 37 ++++++++++++++--- .../react/src/components/primitive/button.tsx | 35 ++++++++++++---- .../src/components/primitive/clear-button.tsx | 40 +++++++++++++++---- .../src/components/primitive/dropzone.tsx | 36 ++++++++++++++--- .../react/src/components/primitive/root.tsx | 20 ++++++++-- packages/react/src/utils/forwardRefWithAs.ts | 16 ++++++++ 6 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 packages/react/src/utils/forwardRefWithAs.ts diff --git a/packages/react/src/components/primitive/allowed-content.tsx b/packages/react/src/components/primitive/allowed-content.tsx index a692b6c470..fd9c059976 100644 --- a/packages/react/src/components/primitive/allowed-content.tsx +++ b/packages/react/src/components/primitive/allowed-content.tsx @@ -1,23 +1,48 @@ +import { ElementType, Ref } from "react"; + import { allowedContentTextLabelGenerator } from "@uploadthing/shared"; +import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; import { + HasDisplayName, PrimitiveComponentProps, PrimitiveSlot, + RefProp, usePrimitiveValues, } from "./root"; -export function AllowedContent({ - children, - ...props -}: PrimitiveComponentProps<"div">) { +const DEFAULT_ALLOWED_CONTENT_TAG = "div" as const; + +export type PrimitiveAllowedContentProps< + Tag extends ElementType = typeof DEFAULT_ALLOWED_CONTENT_TAG, +> = PrimitiveComponentProps; + +export function AllowedContentFn< + Tag 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 index 181d0a11fa..2be4d8ba92 100644 --- a/packages/react/src/components/primitive/button.tsx +++ b/packages/react/src/components/primitive/button.tsx @@ -1,16 +1,26 @@ "use client"; +import { ElementType, Ref } from "react"; + +import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; import { + HasDisplayName, PrimitiveComponentProps, PrimitiveSlot, + RefProp, usePrimitiveValues, } from "./root"; -export function Button({ - children, - onClick, - ...props -}: PrimitiveComponentProps<"label">) { +const DEFAULT_BUTTON_TAG = "label" as const; + +export type PrimitiveButtonProps< + Tag extends ElementType = typeof DEFAULT_BUTTON_TAG, +> = PrimitiveComponentProps; + +function ButtonFn( + { children, onClick, as, ...props }: PrimitiveButtonProps, + ref: Ref, +) { const { refs, disabled, @@ -24,8 +34,10 @@ export function Button({ uploadFiles, } = usePrimitiveValues("Button"); + const Comp = as ?? DEFAULT_BUTTON_TAG; + return ( - + ); } + +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 index 945f49f6f0..7e15c3e757 100644 --- a/packages/react/src/components/primitive/clear-button.tsx +++ b/packages/react/src/components/primitive/clear-button.tsx @@ -1,28 +1,52 @@ "use client"; +import { ElementType, Ref } from "react"; + +import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; import { + HasDisplayName, PrimitiveComponentProps, PrimitiveSlot, + RefProp, usePrimitiveValues, } from "./root"; -export function ClearButton({ - children, - onClick, - ...props -}: PrimitiveComponentProps<"label">) { - const { setFiles, state } = usePrimitiveValues("Button"); +const DEFAULT_CLEAR_BUTTON_TAG = "label" as const; + +export type PrimitiveClearButtonProps< + Tag extends ElementType = typeof DEFAULT_CLEAR_BUTTON_TAG, +> = PrimitiveComponentProps; + +function ClearButtonFn< + Tag 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 ( - + ); } + +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 index 91be71c1a3..d44373ce99 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -1,17 +1,28 @@ +import { ElementType, Ref } from "react"; + import { useDropzone } from "@uploadthing/dropzone/react"; import { generateClientDropzoneAccept } from "@uploadthing/shared"; +import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; import { + HasDisplayName, PrimitiveComponentProps, PrimitiveContextMergeProvider, PrimitiveSlot, + RefProp, usePrimitiveValues, } from "./root"; -export function Dropzone({ - children, - ...props -}: PrimitiveComponentProps<"div">) { +const DEFAULT_DROPZONE_TAG = "div" as const; + +export type PrimitiveDropzoneProps< + Tag extends ElementType = typeof DEFAULT_DROPZONE_TAG, +> = PrimitiveComponentProps; + +function DropzoneFn( + { children, as, ...props }: PrimitiveDropzoneProps, + ref: Ref, +) { const { setFiles, options, fileTypes, disabled, state, refs } = usePrimitiveValues("Dropzone"); @@ -22,19 +33,32 @@ export function Dropzone({ disabled, }); + 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; diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 1f7058f003..4aafdc72c1 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -2,6 +2,7 @@ import { createContext, ElementType, ProviderProps, + Ref, RefObject, useCallback, useContext, @@ -120,11 +121,24 @@ export function PrimitiveSlot({ : children; } -export type PrimitiveComponentProps = Omit< - React.ComponentPropsWithRef, +export type HasDisplayName = { + displayName: string; +}; + +export type RefProp = T extends ( + props: any, + ref: Ref, +) => any + ? { ref?: Ref } + : never; + +export type PrimitiveComponentProps = Omit< + React.ComponentPropsWithoutRef, "children" > & - PrimitiveComponentChildrenProp; + PrimitiveComponentChildrenProp & { + as?: Tag; + }; export type PrimitiveComponentChildrenProp = { children?: PrimitiveComponentChildren; 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, + }); +} From 7a26a1a1f3219404ccdf876cdcdae962d62dab14 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:25:08 +0100 Subject: [PATCH 08/39] style: prefix generics with `T` for consistency --- .../src/components/primitive/allowed-content.tsx | 13 +++++++------ packages/react/src/components/primitive/button.tsx | 12 ++++++------ .../react/src/components/primitive/clear-button.tsx | 12 ++++++------ .../react/src/components/primitive/dropzone.tsx | 12 ++++++------ packages/react/src/components/primitive/root.tsx | 6 +++--- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/react/src/components/primitive/allowed-content.tsx b/packages/react/src/components/primitive/allowed-content.tsx index fd9c059976..0a554ef110 100644 --- a/packages/react/src/components/primitive/allowed-content.tsx +++ b/packages/react/src/components/primitive/allowed-content.tsx @@ -14,13 +14,13 @@ import { const DEFAULT_ALLOWED_CONTENT_TAG = "div" as const; export type PrimitiveAllowedContentProps< - Tag extends ElementType = typeof DEFAULT_ALLOWED_CONTENT_TAG, -> = PrimitiveComponentProps; + TTag extends ElementType = typeof DEFAULT_ALLOWED_CONTENT_TAG, +> = PrimitiveComponentProps; export function AllowedContentFn< - Tag extends ElementType = typeof DEFAULT_ALLOWED_CONTENT_TAG, + TTag extends ElementType = typeof DEFAULT_ALLOWED_CONTENT_TAG, >( - { children, as, ...props }: PrimitiveAllowedContentProps, + { children, as, ...props }: PrimitiveAllowedContentProps, ref: Ref, ) { const { routeConfig, state } = usePrimitiveValues("AllowedContent"); @@ -38,8 +38,9 @@ export function AllowedContentFn< } type _internal_ComponentAllowedContent = HasDisplayName & { - ( - props: PrimitiveAllowedContentProps & RefProp, + ( + props: PrimitiveAllowedContentProps & + RefProp, ): JSX.Element; }; diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx index 2be4d8ba92..6273dbf28e 100644 --- a/packages/react/src/components/primitive/button.tsx +++ b/packages/react/src/components/primitive/button.tsx @@ -14,11 +14,11 @@ import { const DEFAULT_BUTTON_TAG = "label" as const; export type PrimitiveButtonProps< - Tag extends ElementType = typeof DEFAULT_BUTTON_TAG, -> = PrimitiveComponentProps; + TTag extends ElementType = typeof DEFAULT_BUTTON_TAG, +> = PrimitiveComponentProps; -function ButtonFn( - { children, onClick, as, ...props }: PrimitiveButtonProps, +function ButtonFn( + { children, onClick, as, ...props }: PrimitiveButtonProps, ref: Ref, ) { const { @@ -78,8 +78,8 @@ function ButtonFn( } type _internal_ComponentButton = HasDisplayName & { - ( - props: PrimitiveButtonProps & RefProp, + ( + props: PrimitiveButtonProps & RefProp, ): JSX.Element; }; diff --git a/packages/react/src/components/primitive/clear-button.tsx b/packages/react/src/components/primitive/clear-button.tsx index 7e15c3e757..8f8bea3976 100644 --- a/packages/react/src/components/primitive/clear-button.tsx +++ b/packages/react/src/components/primitive/clear-button.tsx @@ -14,13 +14,13 @@ import { const DEFAULT_CLEAR_BUTTON_TAG = "label" as const; export type PrimitiveClearButtonProps< - Tag extends ElementType = typeof DEFAULT_CLEAR_BUTTON_TAG, -> = PrimitiveComponentProps; + TTag extends ElementType = typeof DEFAULT_CLEAR_BUTTON_TAG, +> = PrimitiveComponentProps; function ClearButtonFn< - Tag extends ElementType = typeof DEFAULT_CLEAR_BUTTON_TAG, + TTag extends ElementType = typeof DEFAULT_CLEAR_BUTTON_TAG, >( - { children, onClick, as, ...props }: PrimitiveClearButtonProps, + { children, onClick, as, ...props }: PrimitiveClearButtonProps, ref: Ref, ) { const { setFiles, state } = usePrimitiveValues("ClearButton"); @@ -42,8 +42,8 @@ function ClearButtonFn< } type _internal_ComponentClearButton = HasDisplayName & { - ( - props: PrimitiveClearButtonProps & RefProp, + ( + props: PrimitiveClearButtonProps & RefProp, ): JSX.Element; }; diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index d44373ce99..b95d908239 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -16,11 +16,11 @@ import { const DEFAULT_DROPZONE_TAG = "div" as const; export type PrimitiveDropzoneProps< - Tag extends ElementType = typeof DEFAULT_DROPZONE_TAG, -> = PrimitiveComponentProps; + TTag extends ElementType = typeof DEFAULT_DROPZONE_TAG, +> = PrimitiveComponentProps; -function DropzoneFn( - { children, as, ...props }: PrimitiveDropzoneProps, +function DropzoneFn( + { children, as, ...props }: PrimitiveDropzoneProps, ref: Ref, ) { const { setFiles, options, fileTypes, disabled, state, refs } = @@ -54,8 +54,8 @@ function DropzoneFn( } type _internal_ComponentDropzone = HasDisplayName & { - ( - props: PrimitiveDropzoneProps & RefProp, + ( + props: PrimitiveDropzoneProps & RefProp, ): JSX.Element; }; diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 4aafdc72c1..2a3cc98781 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -132,12 +132,12 @@ export type RefProp = T extends ( ? { ref?: Ref } : never; -export type PrimitiveComponentProps = Omit< - React.ComponentPropsWithoutRef, +export type PrimitiveComponentProps = Omit< + React.ComponentPropsWithoutRef, "children" > & PrimitiveComponentChildrenProp & { - as?: Tag; + as?: TTag; }; export type PrimitiveComponentChildrenProp = { From f546640ca0336641dcf32e5b6369f2311ecba52d Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:29:44 +0100 Subject: [PATCH 09/39] fix: add the 'use client' directive to primitive components --- packages/react/src/components/primitive/allowed-content.tsx | 2 ++ packages/react/src/components/primitive/dropzone.tsx | 2 ++ packages/react/src/components/primitive/index.tsx | 2 ++ packages/react/src/components/primitive/root.tsx | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/react/src/components/primitive/allowed-content.tsx b/packages/react/src/components/primitive/allowed-content.tsx index 0a554ef110..970ef1c9ea 100644 --- a/packages/react/src/components/primitive/allowed-content.tsx +++ b/packages/react/src/components/primitive/allowed-content.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ElementType, Ref } from "react"; import { allowedContentTextLabelGenerator } from "@uploadthing/shared"; diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index b95d908239..284282e04c 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ElementType, Ref } from "react"; import { useDropzone } from "@uploadthing/dropzone/react"; diff --git a/packages/react/src/components/primitive/index.tsx b/packages/react/src/components/primitive/index.tsx index 0a3bce8c91..aee829a210 100644 --- a/packages/react/src/components/primitive/index.tsx +++ b/packages/react/src/components/primitive/index.tsx @@ -1,3 +1,5 @@ +"use client"; + export { Root } from "./root"; export { Button } from "./button"; export { Dropzone } from "./dropzone"; diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 2a3cc98781..b8230e3d2a 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -1,3 +1,5 @@ +"use client"; + import { createContext, ElementType, From 5f12570bac3ca99affb7b812661aa0430dd8fe3b Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:36:55 +0100 Subject: [PATCH 10/39] refactor: dropzone component to use primitives --- packages/react/src/components/dropzone.tsx | 439 ++++++++------------- 1 file changed, 158 insertions(+), 281 deletions(-) diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx index 64ef9fbcd7..2402257de1 100644 --- a/packages/react/src/components/dropzone.tsx +++ b/packages/react/src/components/dropzone.tsx @@ -1,19 +1,10 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { useDropzone } from "@uploadthing/dropzone/react"; import { - allowedContentTextLabelGenerator, contentFieldToContent, defaultClassListMerger, - generateClientDropzoneAccept, - generatePermittedFileTypes, - getFilesFromClipboardEvent, - resolveMaybeUrlArg, styleFieldToClassName, styleFieldToCssObject, - UploadAbortedError, } from "@uploadthing/shared"; import type { ContentField, @@ -23,7 +14,7 @@ import type { import type { FileRouter } from "uploadthing/types"; import type { UploadthingComponentProps } from "../types"; -import { INTERNAL_uploadthingHookGen } from "../useUploadThing"; +import * as Primitive from "./primitive"; import { Cancel, progressWidths, Spinner } from "./shared"; type DropzoneStyleFieldCallbackArgs = { @@ -100,279 +91,165 @@ 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 fileRouteInput = "input" in $props ? $props.input : undefined; - - const { - mode = "manual", - appendOnPaste = false, - cn = defaultClassListMerger, - } = $props.config ?? {}; - const acRef = useRef(new AbortController()); - - const useUploadThing = INTERNAL_uploadthingHookGen({ - url: resolveMaybeUrlArg($props.url), - }); - - const [files, setFiles] = useState([]); - - const [uploadProgressState, setUploadProgress] = useState( - $props.__internal_upload_progress ?? 0, - ); - const uploadProgress = - $props.__internal_upload_progress ?? uploadProgressState; - const { startUpload, isUploading, routeConfig } = useUploadThing( - $props.endpoint, - { - signal: acRef.current.signal, - headers: $props.headers, - onClientUploadComplete: (res) => { - setFiles([]); - void $props.onClientUploadComplete?.(res); - setUploadProgress(0); - }, - onUploadProgress: (p) => { - setUploadProgress(p); - $props.onUploadProgress?.(p); - }, - onUploadError: $props.onUploadError, - onUploadBegin: $props.onUploadBegin, - onBeforeUploadBegin: $props.onBeforeUploadBegin, - }, - ); - - const uploadFiles = useCallback( - async (files: File[]) => { - await startUpload(files, fileRouteInput).catch((e) => { - if (e instanceof UploadAbortedError) { - void $props.onUploadAborted?.(); - } else { - throw e; - } - }); - }, - [$props, startUpload, fileRouteInput], - ); - - const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); - - const onDrop = useCallback( - (acceptedFiles: File[]) => { - $props.onDrop?.(acceptedFiles); - $props.onChange?.(acceptedFiles); - - setFiles(acceptedFiles); - - // If mode is auto, start upload immediately - if (mode === "auto") void uploadFiles(acceptedFiles); - }, - [$props, mode, uploadFiles], - ); - - const isDisabled = (() => { - if ($props.__internal_dropzone_disabled) return true; - if ($props.disabled) return true; - - return false; - })(); - - const { getRootProps, getInputProps, isDragActive, rootRef } = useDropzone({ - onDrop, - multiple, - accept: fileTypes ? generateClientDropzoneAccept(fileTypes) : undefined, - disabled: isDisabled, - }); + const { className, content, appearance, ...rootProps } = + props as unknown as UploadDropzoneProps & + UploadThingInternalProps; - const ready = - $props.__internal_ready ?? - ($props.__internal_state === "ready" || fileTypes.length > 0); - - const onUploadClick = async ( - 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(); - - await uploadFiles(files); - } - }; - - useEffect(() => { - const handlePaste = (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") void uploadFiles(filesToUpload); - }; - - window.addEventListener("paste", handlePaste); - return () => { - window.removeEventListener("paste", handlePaste); - }; - }, [uploadFiles, $props, appendOnPaste, mode, fileTypes, rootRef, files]); - - const getUploadButtonContents = () => { - const customContent = contentFieldToContent( - $props.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 (mode === "manual" && files.length > 0) { - return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`; - } - return `Choose File${multiple ? `(s)` : ``}`; - } - } - }; - - const styleFieldArg = { - fileTypes, - isDragActive, - isUploading, - ready, - uploadProgress, - } as DropzoneStyleFieldCallbackArgs; - - const state = (() => { - if ($props.__internal_state) return $props.__internal_state; - if (isDisabled) return "disabled"; - if (!ready) return "readying"; - if (ready && !isUploading) return "ready"; - - return "uploading"; - })(); + const cn = defaultClassListMerger ?? rootProps.config ?? {}; return ( -
- {contentFieldToContent($props.content?.uploadIcon, styleFieldArg) ?? ( - - - - )} - -
- {contentFieldToContent($props.content?.allowedContent, styleFieldArg) ?? - allowedContentTextLabelGenerator(routeConfig)} -
- - -
+ {...(rootProps as any)}> + + {({ + files, + fileTypes, + dropzone, + isUploading, + ready, + uploadProgress, + state, + options, + }) => { + const styleFieldArg = { + fileTypes, + isDragActive: !!dropzone?.isDragActive, + isUploading, + ready, + uploadProgress, + } 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)` : ``}`; + } + } + }; + + return ( +
+ {contentFieldToContent(content?.uploadIcon, styleFieldArg) ?? ( + + + + )} + + + + + {contentFieldToContent(content?.allowedContent, styleFieldArg)} + + + + {getUploadButtonContents()} + +
+ ); + }} +
+ ); } From 4a9abce7f5003cbb729273d8e1231117e7c3bf84 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:48:17 +0100 Subject: [PATCH 11/39] todo: remove redundant import --- packages/react/src/components/dropzone.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx index 454f88da66..2f0b871363 100644 --- a/packages/react/src/components/dropzone.tsx +++ b/packages/react/src/components/dropzone.tsx @@ -8,7 +8,6 @@ import { } from "@uploadthing/shared"; import type { ContentField, - DropzoneOptions, ErrorMessage, StyleField, } from "@uploadthing/shared"; @@ -253,4 +252,5 @@ export function UploadDropzone< ); -} \ No newline at end of file +} + From 3739b2d8f945e0b5842d25aa06ea18115a9ab361 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:54:00 +0100 Subject: [PATCH 12/39] todo: re-add use dropzone hook into primitive dropzone --- .../src/components/primitive/dropzone.tsx | 354 +++++++++++++++++- packages/react/src/index.ts | 2 +- 2 files changed, 349 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index 284282e04c..96e5c358e8 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -1,18 +1,47 @@ "use client"; -import { ElementType, Ref } from "react"; +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + type ElementType, + type Ref, +} from "react"; +import type { + ChangeEvent, + DragEvent, + HTMLProps, + KeyboardEvent, + MouseEvent, +} from "react"; +import { fromEvent } from "file-selector"; -import { useDropzone } from "@uploadthing/dropzone/react"; -import { generateClientDropzoneAccept } from "@uploadthing/shared"; +import { + acceptPropAsAcceptAttr, + allFilesAccepted, + generateClientDropzoneAccept, + initialState, + isEnterOrSpace, + isEventWithFiles, + isFileAccepted, + isIeOrEdge, + isValidQuantity, + isValidSize, + noop, + reducer, + type DropzoneOptions, +} from "@uploadthing/shared"; import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; import { - HasDisplayName, - PrimitiveComponentProps, PrimitiveContextMergeProvider, PrimitiveSlot, - RefProp, usePrimitiveValues, + type HasDisplayName, + type PrimitiveComponentProps, + type RefProp, } from "./root"; const DEFAULT_DROPZONE_TAG = "div" as const; @@ -64,3 +93,316 @@ type _internal_ComponentDropzone = HasDisplayName & { 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 && event.dataTransfer !== null) { + 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(); + }, []); + + // 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 + isIeOrEdge() ? setTimeout(openFileDialog, 0) : 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/index.ts b/packages/react/src/index.ts index 24f622fea3..d2d00944b7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,5 +12,5 @@ export { generateReactHelpers } from "./useUploadThing"; export type * from "./types"; -export { useDropzone } from "./components/dropzone"; +export { useDropzone } from "./components/primitive/dropzone"; export type * from "./components/dropzone"; From 314da616727177ac076c8f0339700f388dc60385 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:22:34 +0100 Subject: [PATCH 13/39] style: consistency with import type statements --- .../src/components/primitive/allowed-content.tsx | 11 +++-------- .../react/src/components/primitive/button.tsx | 11 +++-------- .../src/components/primitive/clear-button.tsx | 11 +++-------- .../react/src/components/primitive/dropzone.tsx | 16 ++++------------ packages/react/src/components/primitive/root.tsx | 5 +---- 5 files changed, 14 insertions(+), 40 deletions(-) diff --git a/packages/react/src/components/primitive/allowed-content.tsx b/packages/react/src/components/primitive/allowed-content.tsx index 970ef1c9ea..6cffb83105 100644 --- a/packages/react/src/components/primitive/allowed-content.tsx +++ b/packages/react/src/components/primitive/allowed-content.tsx @@ -1,17 +1,12 @@ "use client"; -import { ElementType, Ref } from "react"; +import type { ElementType, Ref } from "react"; import { allowedContentTextLabelGenerator } from "@uploadthing/shared"; import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; -import { - HasDisplayName, - PrimitiveComponentProps, - PrimitiveSlot, - RefProp, - usePrimitiveValues, -} from "./root"; +import { PrimitiveSlot, usePrimitiveValues } from "./root"; +import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root"; const DEFAULT_ALLOWED_CONTENT_TAG = "div" as const; diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx index 6273dbf28e..5a80f65246 100644 --- a/packages/react/src/components/primitive/button.tsx +++ b/packages/react/src/components/primitive/button.tsx @@ -1,15 +1,10 @@ "use client"; -import { ElementType, Ref } from "react"; +import type { ElementType, Ref } from "react"; import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; -import { - HasDisplayName, - PrimitiveComponentProps, - PrimitiveSlot, - RefProp, - usePrimitiveValues, -} from "./root"; +import { PrimitiveSlot, usePrimitiveValues } from "./root"; +import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root"; const DEFAULT_BUTTON_TAG = "label" as const; diff --git a/packages/react/src/components/primitive/clear-button.tsx b/packages/react/src/components/primitive/clear-button.tsx index 8f8bea3976..0bbeb78c3c 100644 --- a/packages/react/src/components/primitive/clear-button.tsx +++ b/packages/react/src/components/primitive/clear-button.tsx @@ -1,15 +1,10 @@ "use client"; -import { ElementType, Ref } from "react"; +import type { ElementType, Ref } from "react"; import { forwardRefWithAs } from "../../utils/forwardRefWithAs"; -import { - HasDisplayName, - PrimitiveComponentProps, - PrimitiveSlot, - RefProp, - usePrimitiveValues, -} from "./root"; +import { PrimitiveSlot, usePrimitiveValues } from "./root"; +import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root"; const DEFAULT_CLEAR_BUTTON_TAG = "label" as const; diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index 96e5c358e8..0f82d8a9e0 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -1,20 +1,14 @@ "use client"; -import { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - type ElementType, - type Ref, -} from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import type { ChangeEvent, DragEvent, + ElementType, HTMLProps, KeyboardEvent, MouseEvent, + Ref, } from "react"; import { fromEvent } from "file-selector"; @@ -39,10 +33,8 @@ import { PrimitiveContextMergeProvider, PrimitiveSlot, usePrimitiveValues, - type HasDisplayName, - type PrimitiveComponentProps, - type RefProp, } from "./root"; +import type { HasDisplayName, PrimitiveComponentProps, RefProp } from "./root"; const DEFAULT_DROPZONE_TAG = "div" as const; diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index b8230e3d2a..bad152f7d7 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -2,15 +2,12 @@ import { createContext, - ElementType, - ProviderProps, - Ref, - RefObject, useCallback, useContext, useRef, useState, } from "react"; +import type { ElementType, ProviderProps, Ref, RefObject } from "react"; import { generateMimeTypes, From 6a395b1fa77d7cab2e3b344a313acec8943d689e Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:39:23 +0100 Subject: [PATCH 14/39] chore: add basic styling to the examples --- examples/minimal-appdir/src/app/page.tsx | 32 ++++++++++++++----- examples/minimal-pagedir/src/pages/index.tsx | 32 ++++++++++++++----- examples/with-clerk-appdir/src/app/page.tsx | 32 ++++++++++++++----- .../with-clerk-pagesdir/src/pages/index.tsx | 32 ++++++++++++++----- 4 files changed, 96 insertions(+), 32 deletions(-) diff --git a/examples/minimal-appdir/src/app/page.tsx b/examples/minimal-appdir/src/app/page.tsx index 67f12092dd..7189ada0bf 100644 --- a/examples/minimal-appdir/src/app/page.tsx +++ b/examples/minimal-appdir/src/app/page.tsx @@ -72,15 +72,31 @@ export default function Home() { }} /> - + {({ dropzone, isUploading }) => ( - <> - {isUploading ? "Uploading" : "Upload file"} -
- -
- {dropzone?.isDragActive && Dragging} - +
+

+ Drag and drop +

+ + {isUploading ? "Uploading" : "Upload file"} + + +
)}
diff --git a/examples/minimal-pagedir/src/pages/index.tsx b/examples/minimal-pagedir/src/pages/index.tsx index 56fa754ca0..33c4712bd4 100644 --- a/examples/minimal-pagedir/src/pages/index.tsx +++ b/examples/minimal-pagedir/src/pages/index.tsx @@ -56,15 +56,31 @@ export default function Home() { }} /> - + {({ dropzone, isUploading }) => ( - <> - {isUploading ? "Uploading" : "Upload file"} -
- -
- {dropzone?.isDragActive && Dragging} - +
+

+ Drag and drop +

+ + {isUploading ? "Uploading" : "Upload file"} + + +
)}
diff --git a/examples/with-clerk-appdir/src/app/page.tsx b/examples/with-clerk-appdir/src/app/page.tsx index 5e180ff18f..9a1dca0750 100644 --- a/examples/with-clerk-appdir/src/app/page.tsx +++ b/examples/with-clerk-appdir/src/app/page.tsx @@ -36,15 +36,31 @@ export default function Home() { }} /> - + {({ dropzone, isUploading }) => ( - <> - {isUploading ? "Uploading" : "Upload file"} -
- -
- {dropzone?.isDragActive && Dragging} - +
+

+ Drag and drop +

+ + {isUploading ? "Uploading" : "Upload file"} + + +
)}
diff --git a/examples/with-clerk-pagesdir/src/pages/index.tsx b/examples/with-clerk-pagesdir/src/pages/index.tsx index c1bc2f8440..c501d14ca3 100644 --- a/examples/with-clerk-pagesdir/src/pages/index.tsx +++ b/examples/with-clerk-pagesdir/src/pages/index.tsx @@ -37,15 +37,31 @@ export default function Home() { }} /> - + {({ dropzone, isUploading }) => ( - <> - {isUploading ? "Uploading" : "Upload file"} -
- -
- {dropzone?.isDragActive && Dragging} - +
+

+ Drag and drop +

+ + {isUploading ? "Uploading" : "Upload file"} + + +
)}
From 1d4d5c21090b941a3dd65a7f1e49c86de5526882 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:50:50 +0100 Subject: [PATCH 15/39] chore: `onCompleteUploadComplete` and `onUploadBegin` props added for primitives in examples --- examples/minimal-appdir/src/app/page.tsx | 11 ++++++++++- examples/minimal-pagedir/src/pages/index.tsx | 11 ++++++++++- examples/with-clerk-appdir/src/app/page.tsx | 11 ++++++++++- examples/with-clerk-pagesdir/src/pages/index.tsx | 11 ++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/examples/minimal-appdir/src/app/page.tsx b/examples/minimal-appdir/src/app/page.tsx index 7189ada0bf..35a25e2524 100644 --- a/examples/minimal-appdir/src/app/page.tsx +++ b/examples/minimal-appdir/src/app/page.tsx @@ -71,7 +71,16 @@ export default function Home() { await startUpload(files); }} /> - + { + console.log(`onClientUploadComplete`, res); + alert("Upload Completed"); + }} + onUploadBegin={() => { + console.log("upload begin"); + }} + > {({ dropzone, isUploading }) => (
- + { + console.log(`onClientUploadComplete`, res); + alert("Upload Completed"); + }} + onUploadBegin={() => { + console.log("upload begin"); + }} + > {({ dropzone, isUploading }) => (
- + { + console.log(`onClientUploadComplete`, res); + alert("Upload Completed"); + }} + onUploadBegin={() => { + console.log("upload begin"); + }} + > {({ dropzone, isUploading }) => (
- + { + console.log(`onClientUploadComplete`, res); + alert("Upload Completed"); + }} + onUploadBegin={() => { + console.log("upload begin"); + }} + > {({ dropzone, isUploading }) => (
Date: Thu, 26 Sep 2024 21:52:39 +0100 Subject: [PATCH 16/39] fix: disabled accessibility for primitive button components --- packages/react/src/components/primitive/button.tsx | 1 + packages/react/src/components/primitive/clear-button.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx index 5a80f65246..e39b2ce2c5 100644 --- a/packages/react/src/components/primitive/button.tsx +++ b/packages/react/src/components/primitive/button.tsx @@ -35,6 +35,7 @@ function ButtonFn( { onClick?.(e); if (state === "uploading") { diff --git a/packages/react/src/components/primitive/clear-button.tsx b/packages/react/src/components/primitive/clear-button.tsx index 0bbeb78c3c..215119dead 100644 --- a/packages/react/src/components/primitive/clear-button.tsx +++ b/packages/react/src/components/primitive/clear-button.tsx @@ -18,13 +18,14 @@ function ClearButtonFn< { children, onClick, as, ...props }: PrimitiveClearButtonProps, ref: Ref, ) { - const { setFiles, state } = usePrimitiveValues("ClearButton"); + const { setFiles, state, disabled } = usePrimitiveValues("ClearButton"); const Comp = as ?? DEFAULT_CLEAR_BUTTON_TAG; return ( { onClick?.(e); setFiles([]); From 29469b3a144a69e82957a8e79c25f945e07a0681 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:54:39 +0100 Subject: [PATCH 17/39] fix: useUncontrolledState potential issue with falsy value --- packages/react/src/utils/useControllableState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/utils/useControllableState.ts b/packages/react/src/utils/useControllableState.ts index e18100a330..2894bbcda8 100644 --- a/packages/react/src/utils/useControllableState.ts +++ b/packages/react/src/utils/useControllableState.ts @@ -39,7 +39,7 @@ const useUncontrolledState = ({ const handleChange = useCallbackRef(onChange); React.useEffect(() => { - if (!value) return; + if (value === undefined) return; if (prevValueRef.current !== value) { handleChange(value); prevValueRef.current = value; From d1dd53ca10bc0ecff98a0a6c0cea7ddfa453efe7 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:56:35 +0100 Subject: [PATCH 18/39] fix: dropzone and button components not using custom cn --- packages/react/src/components/button.tsx | 2 +- packages/react/src/components/dropzone.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx index 941410b0ab..097c4ec7bf 100644 --- a/packages/react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -78,7 +78,7 @@ export function UploadButton< const { className, content, appearance, ...$props } = props as unknown as UploadButtonProps; - const cn = defaultClassListMerger ?? $props.config ?? {}; + const cn = $props.config?.cn ?? defaultClassListMerger; return ( {...($props as any)}> diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx index 2f0b871363..c7f951ea00 100644 --- a/packages/react/src/components/dropzone.tsx +++ b/packages/react/src/components/dropzone.tsx @@ -95,7 +95,7 @@ export function UploadDropzone< props as unknown as UploadDropzoneProps & UploadThingInternalProps; - const cn = defaultClassListMerger ?? rootProps.config ?? {}; + const cn = rootProps.config?.cn ?? defaultClassListMerger; return ( {...(rootProps as any)}> @@ -253,4 +253,3 @@ export function UploadDropzone< ); } - From 1330c72eb9e38fce5437dec6aa92975f3132aca3 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:57:10 +0100 Subject: [PATCH 19/39] refactor: button component to use `rootProps` variable name for consistency --- packages/react/src/components/button.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx index 097c4ec7bf..79b82e04bb 100644 --- a/packages/react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -75,13 +75,13 @@ export function UploadButton< ) { // Cast back to UploadthingComponentProps to get the correct type // since the ErrorMessage messes it up otherwise - const { className, content, appearance, ...$props } = + const { className, content, appearance, ...rootProps } = props as unknown as UploadButtonProps; - const cn = $props.config?.cn ?? defaultClassListMerger; + const cn = rootProps.config?.cn ?? defaultClassListMerger; return ( - {...($props as any)}> + {...(rootProps as any)}> {({ state, uploadProgress, fileTypes, files, options }) => { const styleFieldArg = { ready: state !== "readying", From 753c95d9c833f9e05e6bd8549647c5efdc380c47 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:58:31 +0100 Subject: [PATCH 20/39] refactor: use more precise function type for `RefProp` --- packages/react/src/components/primitive/root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index bad152f7d7..94c8021534 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -124,7 +124,7 @@ export type HasDisplayName = { displayName: string; }; -export type RefProp = T extends ( +export type RefProp any> = T extends ( props: any, ref: Ref, ) => any From fc729786f043eda6924b967d26a04d7a70aff00c Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Thu, 26 Sep 2024 22:00:28 +0100 Subject: [PATCH 21/39] perf: optimise useEffect deps in the root primitive component --- packages/react/src/components/primitive/root.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 94c8021534..05ebb1bf3c 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -223,17 +223,18 @@ export function Root< }, ); + const { onUploadAborted } = $props; const uploadFiles = useCallback( (files: File[]) => { startUpload(files, fileRouteInput).catch((e) => { if (e instanceof UploadAbortedError) { - void $props.onUploadAborted?.(); + void onUploadAborted?.(); } else { throw e; } }); }, - [$props, startUpload, fileRouteInput], + [onUploadAborted, startUpload, fileRouteInput], ); const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); From 3c1d7dc242f907e6eaef1245095e3dfd6c3b1cae Mon Sep 17 00:00:00 2001 From: Aria <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:17:08 +0100 Subject: [PATCH 22/39] remove redundant type guards in the root primitive component Co-authored-by: Julius Marminge --- packages/react/src/components/primitive/root.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 05ebb1bf3c..85e386cb91 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -167,14 +167,8 @@ export type RootPrimitiveComponentProps< export function Root< TRouter extends FileRouter, TEndpoint extends keyof TRouter, ->( - props: FileRouter extends TRouter - ? ErrorMessage<"You forgot to pass the generic"> - : RootPrimitiveComponentProps, -) { - // Cast back to UploadthingComponentProps to get the correct type - // since the ErrorMessage messes it up otherwise - const $props = props as unknown as RootPrimitiveComponentProps< +>(props: RootPrimitiveComponentProps) { + props; TRouter, TEndpoint > & From f9bb2a8ff13f768c68e1a097c37a7d40007c4b7c Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:23:21 +0100 Subject: [PATCH 23/39] fix: root primitive syntax --- .../react/src/components/primitive/root.tsx | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 85e386cb91..2ea7286084 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -167,57 +167,54 @@ export type RootPrimitiveComponentProps< export function Root< TRouter extends FileRouter, TEndpoint extends keyof TRouter, ->(props: RootPrimitiveComponentProps) { - props; - TRouter, - TEndpoint - > & - UploadThingInternalProps; +>( + props: RootPrimitiveComponentProps & + UploadThingInternalProps, +) { + const fileRouteInput = "input" in props ? props.input : undefined; - const fileRouteInput = "input" in $props ? $props.input : undefined; - - const { mode = "auto", appendOnPaste = false } = $props.config ?? {}; + const { mode = "auto", appendOnPaste = false } = props.config ?? {}; const acRef = useRef(new AbortController()); const useUploadThing = INTERNAL_uploadthingHookGen({ - url: resolveMaybeUrlArg($props.url), + url: resolveMaybeUrlArg(props.url), }); const focusElementRef = useRef(null); const fileInputRef = useRef(null); const [uploadProgress, setUploadProgress] = useState( - $props.__internal_upload_progress ?? 0, + props.__internal_upload_progress ?? 0, ); const [files, setFiles] = useControllableState({ - prop: $props.files, - onChange: $props.onFilesChange, + prop: props.files, + onChange: props.onFilesChange, defaultProp: [], }); const { startUpload, isUploading, routeConfig } = useUploadThing( - $props.endpoint, + props.endpoint, { signal: acRef.current.signal, - headers: $props.headers, + headers: props.headers, onClientUploadComplete: (res) => { if (fileInputRef.current) { fileInputRef.current.value = ""; } setFiles([]); - void $props.onClientUploadComplete?.(res); + void props.onClientUploadComplete?.(res); setUploadProgress(0); }, onUploadProgress: (p) => { setUploadProgress(p); - $props.onUploadProgress?.(p); + props.onUploadProgress?.(p); }, - onUploadError: $props.onUploadError, - onUploadBegin: $props.onUploadBegin, - onBeforeUploadBegin: $props.onBeforeUploadBegin, + onUploadError: props.onUploadError, + onUploadBegin: props.onUploadBegin, + onBeforeUploadBegin: props.onBeforeUploadBegin, }, ); - const { onUploadAborted } = $props; + const { onUploadAborted } = props; const uploadFiles = useCallback( (files: File[]) => { startUpload(files, fileRouteInput).catch((e) => { @@ -234,13 +231,13 @@ export function Root< const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); let disabled = fileTypes.length === 0; - if ($props.disabled) disabled = true; - if ($props.__internal_button_disabled) disabled = true; + if (props.disabled) disabled = true; + if (props.__internal_button_disabled) disabled = true; const accept = generateMimeTypes(fileTypes).join(", "); const state = (() => { - if ($props.__internal_state) return $props.__internal_state; + if (props.__internal_state) return props.__internal_state; if (disabled) return "disabled"; if (!disabled && !isUploading) return "ready"; return "uploading"; @@ -259,7 +256,7 @@ export function Root< setFiles((prev) => { filesToUpload = [...prev, ...pastedFiles]; - $props.onChange?.(filesToUpload); + props.onChange?.(filesToUpload); return filesToUpload; }); @@ -271,7 +268,7 @@ export function Root< files, setFiles: (files) => { setFiles(files); - $props.onChange?.(files); + props.onChange?.(files); if (files.length <= 0) { if (fileInputRef.current) fileInputRef.current.value = ""; @@ -303,7 +300,7 @@ export function Root< return ( - {$props.children} + {props.children} ); } From 3b51eaa5222a5fd54990ab10185d649a789c3808 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:27:02 +0100 Subject: [PATCH 24/39] feat: disabled prop on dropzone primitive --- .../src/components/primitive/dropzone.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index 0f82d8a9e0..eea602aef5 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -40,20 +40,31 @@ const DEFAULT_DROPZONE_TAG = "div" as const; export type PrimitiveDropzoneProps< TTag extends ElementType = typeof DEFAULT_DROPZONE_TAG, -> = PrimitiveComponentProps; +> = PrimitiveComponentProps & { disabled?: boolean }; function DropzoneFn( - { children, as, ...props }: PrimitiveDropzoneProps, + { + children, + as, + disabled: componentDisabled, + ...props + }: PrimitiveDropzoneProps, ref: Ref, ) { - const { setFiles, options, fileTypes, disabled, state, refs } = - usePrimitiveValues("Dropzone"); + const { + setFiles, + options, + fileTypes, + disabled: rootDisabled, + state, + refs, + } = usePrimitiveValues("Dropzone"); const { getRootProps, getInputProps, isDragActive, rootRef } = useDropzone({ onDrop: setFiles, multiple: options.multiple, accept: fileTypes ? generateClientDropzoneAccept(fileTypes) : undefined, - disabled, + disabled: rootDisabled || componentDisabled, }); const Comp = as ?? DEFAULT_DROPZONE_TAG; From 46678ecf2a54bd1e4599d1ea773eb74549d51505 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:34:07 +0100 Subject: [PATCH 25/39] fix: allow undefined on disabled prop --- packages/react/src/components/primitive/dropzone.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index eea602aef5..87fa28d0d5 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -40,7 +40,7 @@ const DEFAULT_DROPZONE_TAG = "div" as const; export type PrimitiveDropzoneProps< TTag extends ElementType = typeof DEFAULT_DROPZONE_TAG, -> = PrimitiveComponentProps & { disabled?: boolean }; +> = PrimitiveComponentProps & { disabled?: boolean | undefined }; function DropzoneFn( { From a7b146a296db56a4cac2cde80d63f5d429f9d248 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:40:26 +0100 Subject: [PATCH 26/39] fix: internal component props --- packages/react/src/components/button.tsx | 22 ++++++++++++++++--- packages/react/src/components/dropzone.tsx | 18 +++++++++------ .../react/src/components/primitive/root.tsx | 14 +++++++----- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx index 79b82e04bb..36f6ef8ef8 100644 --- a/packages/react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -56,6 +56,13 @@ export type UploadButtonProps< content?: ButtonContent; }; +/** 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"; + __internal_upload_progress?: number; + __internal_button_disabled?: boolean; +}; + /** * @remarks It is not recommended using this directly as it requires manually binding generics. Instead, use `createUploadButton`. * @example @@ -75,13 +82,22 @@ export function UploadButton< ) { // Cast back to UploadthingComponentProps to get the correct type // since the ErrorMessage messes it up otherwise - const { className, content, appearance, ...rootProps } = - props as unknown as UploadButtonProps; + const { + className, + content, + appearance, + __internal_button_disabled, + ...rootProps + } = props as unknown as UploadButtonProps & + UploadThingInternalProps; const cn = rootProps.config?.cn ?? defaultClassListMerger; return ( - {...(rootProps as any)}> + + {...(rootProps as any)} + disabled={__internal_button_disabled} + > {({ state, uploadProgress, fileTypes, files, options }) => { const styleFieldArg = { ready: state !== "readying", diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx index c7f951ea00..3802357285 100644 --- a/packages/react/src/components/dropzone.tsx +++ b/packages/react/src/components/dropzone.tsx @@ -73,8 +73,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 @@ -91,15 +89,21 @@ export function UploadDropzone< ) { // Cast back to UploadthingComponentProps to get the correct type // since the ErrorMessage messes it up otherwise - const { className, content, appearance, ...rootProps } = - props as unknown as UploadDropzoneProps & - UploadThingInternalProps; + const { + className, + content, + appearance, + __internal_dropzone_disabled, + __internal_button_disabled, + ...rootProps + } = props as unknown as UploadDropzoneProps & + UploadThingInternalProps; const cn = rootProps.config?.cn ?? defaultClassListMerger; return ( {...(rootProps as any)}> - + {({ files, fileTypes, @@ -242,7 +246,7 @@ export function UploadDropzone< )} style={styleFieldToCssObject(appearance?.button, styleFieldArg)} data-ut-element="button" - disabled={rootProps.__internal_button_disabled ?? !files.length} + disabled={__internal_button_disabled ?? !files.length} > {getUploadButtonContents()} diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 2ea7286084..eb5b8b642c 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -150,8 +150,10 @@ export type PrimitiveComponentChildren = /** 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; - __internal_button_disabled?: boolean; + // Allow to set ready explicitly and independently of internal state + __internal_ready?: boolean; }; export type RootPrimitiveComponentProps< @@ -230,9 +232,7 @@ export function Root< const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); - let disabled = fileTypes.length === 0; - if (props.disabled) disabled = true; - if (props.__internal_button_disabled) disabled = true; + const disabled = fileTypes.length === 0 || !!props.disabled; const accept = generateMimeTypes(fileTypes).join(", "); @@ -264,6 +264,10 @@ export function Root< if (mode === "auto") void uploadFiles(files); }); + const ready = + props.__internal_ready ?? + (props.__internal_state === "ready" || fileTypes.length > 0); + const primitiveValues: PrimitiveContextValues = { files, setFiles: (files) => { @@ -295,7 +299,7 @@ export function Root< }, routeConfig, isUploading: state === "uploading", - ready: state === "ready", + ready, }; return ( From 1fea66201708c9b1fcc1f724c50930c549198e74 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:43:30 +0100 Subject: [PATCH 27/39] fix: add disabled check on primitive button on click --- packages/react/src/components/primitive/button.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx index e39b2ce2c5..6b1eacc435 100644 --- a/packages/react/src/components/primitive/button.tsx +++ b/packages/react/src/components/primitive/button.tsx @@ -37,6 +37,8 @@ function ButtonFn( data-state={state} aria-disabled={disabled} onClick={(e) => { + if (disabled) return; + onClick?.(e); if (state === "uploading") { e.preventDefault(); From 07424f32fb4f181d41f7014c8c767e7b90547f02 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:45:54 +0100 Subject: [PATCH 28/39] fix: usePaste hook in primtive root not auto uploading the pasted files --- packages/react/src/components/primitive/root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index eb5b8b642c..b2ab33c973 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -261,7 +261,7 @@ export function Root< return filesToUpload; }); - if (mode === "auto") void uploadFiles(files); + if (mode === "auto") void uploadFiles(filesToUpload); }); const ready = From 3b8a3664a228176c3c828463c8ebe982d470acbf Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:46:45 +0100 Subject: [PATCH 29/39] chore: remove redundant import in root primitve --- packages/react/src/components/primitive/root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index b2ab33c973..95281d1d81 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -17,7 +17,6 @@ import { UploadAbortedError, } from "@uploadthing/shared"; import type { - ErrorMessage, ExpandedRouteConfig, FileRouterInputKey, } from "@uploadthing/shared"; From 8faf68f27a26f22f3a1f4c765aff32c8744c1c97 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 2 Oct 2024 19:01:45 +0100 Subject: [PATCH 30/39] fix: backwards compat with onDrop prop --- packages/react/src/components/dropzone.tsx | 6 ++++- .../src/components/primitive/dropzone.tsx | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx index 3802357285..43ad5629a0 100644 --- a/packages/react/src/components/dropzone.tsx +++ b/packages/react/src/components/dropzone.tsx @@ -93,6 +93,7 @@ export function UploadDropzone< className, content, appearance, + onDrop, __internal_dropzone_disabled, __internal_button_disabled, ...rootProps @@ -103,7 +104,10 @@ export function UploadDropzone< return ( {...(rootProps as any)}> - + {({ files, fileTypes, diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index 87fa28d0d5..353f7900c8 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -40,12 +40,22 @@ const DEFAULT_DROPZONE_TAG = "div" as const; export type PrimitiveDropzoneProps< TTag extends ElementType = typeof DEFAULT_DROPZONE_TAG, -> = PrimitiveComponentProps & { disabled?: boolean | undefined }; +> = 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, disabled: componentDisabled, ...props }: PrimitiveDropzoneProps, @@ -60,8 +70,16 @@ function DropzoneFn( refs, } = usePrimitiveValues("Dropzone"); + const onDropCallback = useCallback( + (acceptedFiles: File[]) => { + onDrop?.(acceptedFiles); + setFiles(acceptedFiles); + }, + [setFiles, onDrop], + ); + const { getRootProps, getInputProps, isDragActive, rootRef } = useDropzone({ - onDrop: setFiles, + onDrop: onDropCallback, multiple: options.multiple, accept: fileTypes ? generateClientDropzoneAccept(fileTypes) : undefined, disabled: rootDisabled || componentDisabled, From f49d5c69e3fc9b292ba71ee7bb5cd720c67e1615 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 23 Oct 2024 18:28:16 +0100 Subject: [PATCH 31/39] fix: merge conflicts --- packages/react/src/components/button.tsx | 6 ++-- packages/react/src/components/dropzone.tsx | 25 ++++++--------- packages/react/src/components/index.tsx | 6 ++++ .../react/src/components/primitive/button.tsx | 9 +++--- .../src/components/primitive/clear-button.tsx | 4 +-- .../src/components/primitive/dropzone.tsx | 26 +++++++-------- .../react/src/components/primitive/root.tsx | 32 +++++++------------ 7 files changed, 49 insertions(+), 59 deletions(-) diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx index 3bb0025d1c..37665937dc 100644 --- a/packages/react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -23,6 +23,7 @@ type ButtonStyleFieldCallbackArgs = { isUploading: boolean; uploadProgress: number; fileTypes: string[]; + files: File[]; }; type ButtonAppearance = { @@ -104,6 +105,7 @@ export function UploadButton< isUploading: state === "uploading", uploadProgress: uploadProgress, fileTypes: fileTypes, + files, } as ButtonStyleFieldCallbackArgs; const renderAllowedContent = () => ( @@ -151,7 +153,7 @@ export function UploadButton< return "Loading..."; } case "uploading": { - if (uploadProgress === 100) return ; + if (uploadProgress >= 100) return ; return ( @@ -206,4 +208,4 @@ export function UploadButton< }} ); -} \ No newline at end of file +} diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx index b8e8925c6f..7e075c5c72 100644 --- a/packages/react/src/components/dropzone.tsx +++ b/packages/react/src/components/dropzone.tsx @@ -24,6 +24,7 @@ type DropzoneStyleFieldCallbackArgs = { uploadProgress: number; fileTypes: string[]; isDragActive: boolean; + files: File[]; }; type DropzoneAppearance = { @@ -108,22 +109,14 @@ export function UploadDropzone< onFilesDropped={onDrop} disabled={__internal_dropzone_disabled} > - {({ - files, - fileTypes, - dropzone, - isUploading, - ready, - uploadProgress, - state, - options, - }) => { + {({ files, fileTypes, dropzone, uploadProgress, state, options }) => { const styleFieldArg = { fileTypes, isDragActive: !!dropzone?.isDragActive, - isUploading, - ready, + isUploading: state === "uploading", + ready: state !== "readying", uploadProgress, + files, } as DropzoneStyleFieldCallbackArgs; const getUploadButtonContents = () => { @@ -138,7 +131,7 @@ export function UploadDropzone< return "Loading..."; } case "uploading": { - if (uploadProgress === 100) return ; + if (uploadProgress >= 100) return ; return ( @@ -206,7 +199,7 @@ export function UploadDropzone< @@ -260,4 +253,4 @@ export function UploadDropzone< ); -} \ No newline at end of file +} diff --git a/packages/react/src/components/index.tsx b/packages/react/src/components/index.tsx index 64ab258d43..b33c66b31c 100644 --- a/packages/react/src/components/index.tsx +++ b/packages/react/src/components/index.tsx @@ -63,6 +63,12 @@ export const generateUploadDropzone = ( export const generateUploadPrimitives = ( opts?: GenerateTypedHelpersOptions, ) => { + warnIfInvalidPeerDependency( + "@uploadthing/react", + peerDependencies.uploadthing, + uploadthingClientVersion, + ); + const url = resolveMaybeUrlArg(opts?.url); const TypedUploadRoot = ( diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx index 6b1eacc435..fa812fa579 100644 --- a/packages/react/src/components/primitive/button.tsx +++ b/packages/react/src/components/primitive/button.tsx @@ -18,7 +18,6 @@ function ButtonFn( ) { const { refs, - disabled, setFiles, dropzone, accept, @@ -35,9 +34,9 @@ function ButtonFn( { - if (disabled) return; + if (state === "disabled") return; onClick?.(e); if (state === "uploading") { @@ -66,8 +65,8 @@ function ButtonFn( if (!e.target.files) return; setFiles(Array.from(e.target.files)); }} - disabled={disabled} - tabIndex={disabled ? -1 : 0} + disabled={state === "disabled"} + tabIndex={state === "disabled" ? -1 : 0} className="sr-only" /> )} diff --git a/packages/react/src/components/primitive/clear-button.tsx b/packages/react/src/components/primitive/clear-button.tsx index 215119dead..03361158aa 100644 --- a/packages/react/src/components/primitive/clear-button.tsx +++ b/packages/react/src/components/primitive/clear-button.tsx @@ -18,14 +18,14 @@ function ClearButtonFn< { children, onClick, as, ...props }: PrimitiveClearButtonProps, ref: Ref, ) { - const { setFiles, state, disabled } = usePrimitiveValues("ClearButton"); + const { setFiles, state } = usePrimitiveValues("ClearButton"); const Comp = as ?? DEFAULT_CLEAR_BUTTON_TAG; return ( { onClick?.(e); setFiles([]); diff --git a/packages/react/src/components/primitive/dropzone.tsx b/packages/react/src/components/primitive/dropzone.tsx index 353f7900c8..c8395cb75d 100644 --- a/packages/react/src/components/primitive/dropzone.tsx +++ b/packages/react/src/components/primitive/dropzone.tsx @@ -61,14 +61,8 @@ function DropzoneFn( }: PrimitiveDropzoneProps, ref: Ref, ) { - const { - setFiles, - options, - fileTypes, - disabled: rootDisabled, - state, - refs, - } = usePrimitiveValues("Dropzone"); + const { setFiles, options, fileTypes, state, refs } = + usePrimitiveValues("Dropzone"); const onDropCallback = useCallback( (acceptedFiles: File[]) => { @@ -82,7 +76,7 @@ function DropzoneFn( onDrop: onDropCallback, multiple: options.multiple, accept: fileTypes ? generateClientDropzoneAccept(fileTypes) : undefined, - disabled: rootDisabled || componentDisabled, + disabled: state === "disabled" || componentDisabled, }); const Comp = as ?? DEFAULT_DROPZONE_TAG; @@ -99,7 +93,7 @@ function DropzoneFn( ref={ref} > {children} - + ); @@ -355,9 +349,15 @@ export function useDropzone({ [openFileDialog], ); - const onInputElementClick = useCallback((e: MouseEvent) => { - e.stopPropagation(); - }, []); + 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" }), []); diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index 95281d1d81..e85af25394 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -23,17 +23,13 @@ import type { import type { FileRouter } from "uploadthing/types"; import type { UploadthingComponentProps } from "../../types"; -import { INTERNAL_uploadthingHookGen } from "../../useUploadThing"; +import { __useUploadThingInternal } from "../../useUploadThing"; import { useControllableState } from "../../utils/useControllableState"; import { usePaste } from "../../utils/usePaste"; type PrimitiveContextValues = { state: "readying" | "ready" | "uploading" | "disabled"; - disabled: boolean; - isUploading: boolean; - ready: boolean; - files: File[]; fileTypes: FileRouterInputKey[]; accept: string; @@ -177,10 +173,6 @@ export function Root< const { mode = "auto", appendOnPaste = false } = props.config ?? {}; const acRef = useRef(new AbortController()); - const useUploadThing = INTERNAL_uploadthingHookGen({ - url: resolveMaybeUrlArg(props.url), - }); - const focusElementRef = useRef(null); const fileInputRef = useRef(null); const [uploadProgress, setUploadProgress] = useState( @@ -192,7 +184,8 @@ export function Root< defaultProp: [], }); - const { startUpload, isUploading, routeConfig } = useUploadThing( + const { startUpload, isUploading, routeConfig } = __useUploadThingInternal( + resolveMaybeUrlArg(props.url), props.endpoint, { signal: acRef.current.signal, @@ -231,14 +224,18 @@ export function Root< const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); - const disabled = fileTypes.length === 0 || !!props.disabled; - const accept = generateMimeTypes(fileTypes).join(", "); const state = (() => { if (props.__internal_state) return props.__internal_state; - if (disabled) return "disabled"; - if (!disabled && !isUploading) return "ready"; + + const ready = + props.__internal_ready ?? + (props.__internal_state === "ready" || fileTypes.length > 0); + + if (fileTypes.length === 0 || !!props.disabled) return "disabled"; + if (!ready) return "readying"; + if (ready && !isUploading) return "ready"; return "uploading"; })(); @@ -263,10 +260,6 @@ export function Root< if (mode === "auto") void uploadFiles(filesToUpload); }); - const ready = - props.__internal_ready ?? - (props.__internal_state === "ready" || fileTypes.length > 0); - const primitiveValues: PrimitiveContextValues = { files, setFiles: (files) => { @@ -288,7 +281,6 @@ export function Root< }, uploadProgress, state, - disabled, accept, fileTypes, options: { mode, multiple }, @@ -297,8 +289,6 @@ export function Root< fileInputRef, }, routeConfig, - isUploading: state === "uploading", - ready, }; return ( From 24082c741860b5d5891f6ffa7426a918af5135d6 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 23 Oct 2024 18:34:49 +0100 Subject: [PATCH 32/39] fix: examples to use state instead of isUploading --- examples/minimal-appdir/src/app/page.tsx | 4 ++-- examples/minimal-pagedir/src/pages/index.tsx | 4 ++-- examples/with-clerk-appdir/src/app/page.tsx | 4 ++-- examples/with-clerk-pagesdir/src/pages/index.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/minimal-appdir/src/app/page.tsx b/examples/minimal-appdir/src/app/page.tsx index 1f5220dc3a..4b2a02b0ba 100644 --- a/examples/minimal-appdir/src/app/page.tsx +++ b/examples/minimal-appdir/src/app/page.tsx @@ -82,7 +82,7 @@ export default function Home() { }} > - {({ dropzone, isUploading }) => ( + {({ dropzone, state }) => (
- {isUploading ? "Uploading" : "Upload file"} + {state === "uploading" ? "Uploading" : "Upload file"} - {({ dropzone, isUploading }) => ( + {({ dropzone, state }) => (
- {isUploading ? "Uploading" : "Upload file"} + {state === "uploading" ? "Uploading" : "Upload file"} - {({ dropzone, isUploading }) => ( + {({ dropzone, state }) => (
- {isUploading ? "Uploading" : "Upload file"} + {state === "uploading" ? "Uploading" : "Upload file"} - {({ dropzone, isUploading }) => ( + {({ dropzone, state }) => (
- {isUploading ? "Uploading" : "Upload file"} + {state === "uploading" ? "Uploading" : "Upload file"} Date: Wed, 23 Oct 2024 18:47:44 +0100 Subject: [PATCH 33/39] docs: unstyled primitive components --- docs/src/app/(docs)/concepts/theming/page.mdx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/src/app/(docs)/concepts/theming/page.mdx b/docs/src/app/(docs)/concepts/theming/page.mdx index d28e561f35..8484c1eab5 100644 --- a/docs/src/app/(docs)/concepts/theming/page.mdx +++ b/docs/src/app/(docs)/concepts/theming/page.mdx @@ -509,3 +509,51 @@ 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. + +### Setup + +```ts {{ title: 'src/utils/uploadthing.ts' }} +import { generateUploadPrimitives } from "@uploadthing/react"; + +import type { OurFileRouter } from "~/server/uploadthing"; + +export const UT = generateUploadPrimitives(); +``` + +### Example of Dropzone + +The `dropzone` parameter will only be defined from within children of the `Dropzone` component. + +```jsx + + + {({ dropzone, state }) => ( +
+

Drag and drop

+ + {state === "uploading" ? "Uploading" : "Upload file"} + + +
+ )} +
+
+``` + +### Example of Button + +```jsx + + + {({ state }) => state === "uploading" ? "Uploading" : "Upload file"} + + +``` From b6011964255f30797a58d2a7b8c1a8e3320d99e5 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 23 Oct 2024 18:50:02 +0100 Subject: [PATCH 34/39] fix: add disabled prop to clear button --- packages/react/src/components/primitive/clear-button.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/components/primitive/clear-button.tsx b/packages/react/src/components/primitive/clear-button.tsx index 03361158aa..72fed2e793 100644 --- a/packages/react/src/components/primitive/clear-button.tsx +++ b/packages/react/src/components/primitive/clear-button.tsx @@ -26,7 +26,9 @@ function ClearButtonFn< {...props} data-state={state} aria-disabled={state === "disabled"} + disabled={state === "disabled"} onClick={(e) => { + if (state === "disabled") return; onClick?.(e); setFiles([]); }} From b140b50848f9d8801d53d82373e4ffea24b3b7f0 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 23 Oct 2024 18:50:14 +0100 Subject: [PATCH 35/39] fix: add disabled prop to button --- packages/react/src/components/primitive/button.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/components/primitive/button.tsx b/packages/react/src/components/primitive/button.tsx index fa812fa579..8dfca07f59 100644 --- a/packages/react/src/components/primitive/button.tsx +++ b/packages/react/src/components/primitive/button.tsx @@ -35,6 +35,7 @@ function ButtonFn( {...props} data-state={state} aria-disabled={state === "disabled"} + disabled={state === "disabled"} onClick={(e) => { if (state === "disabled") return; From 7d38176065ac223085df0e6098fa64977bc3f434 Mon Sep 17 00:00:00 2001 From: Aria <85405932+veloii@users.noreply.github.com> Date: Wed, 23 Oct 2024 18:50:54 +0100 Subject: [PATCH 36/39] Update packages/react/src/components/primitive/root.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/react/src/components/primitive/root.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index e85af25394..e620c4f949 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -260,7 +260,7 @@ export function Root< if (mode === "auto") void uploadFiles(filesToUpload); }); - const primitiveValues: PrimitiveContextValues = { + const primitiveValues = useMemo(() => ({ files, setFiles: (files) => { setFiles(files); @@ -289,7 +289,18 @@ export function Root< fileInputRef, }, routeConfig, - }; + }), [ + files, + setFiles, + uploadFiles, + uploadProgress, + state, + accept, + fileTypes, + mode, + multiple, + routeConfig, + ]); return ( From c18d02ceb143d952ce92cc555ae042935bce0116 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 23 Oct 2024 18:51:28 +0100 Subject: [PATCH 37/39] fix: import useMemo --- .../react/src/components/primitive/root.tsx | 82 ++++++++++--------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/packages/react/src/components/primitive/root.tsx b/packages/react/src/components/primitive/root.tsx index e620c4f949..ef09dc4c1c 100644 --- a/packages/react/src/components/primitive/root.tsx +++ b/packages/react/src/components/primitive/root.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, + useMemo, useRef, useState, } from "react"; @@ -260,47 +261,50 @@ export function Root< if (mode === "auto") void uploadFiles(filesToUpload); }); - const primitiveValues = useMemo(() => ({ - files, - setFiles: (files) => { - setFiles(files); - props.onChange?.(files); + 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; + 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, - }), [ - files, - setFiles, - uploadFiles, - uploadProgress, - state, - accept, - fileTypes, - mode, - multiple, - routeConfig, - ]); + 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, + }), + [ + files, + setFiles, + uploadFiles, + uploadProgress, + state, + accept, + fileTypes, + mode, + multiple, + routeConfig, + ], + ); return ( From aac89163518e61248ba679945657a14b0f9cb583 Mon Sep 17 00:00:00 2001 From: veloii <85405932+veloii@users.noreply.github.com> Date: Wed, 23 Oct 2024 19:00:08 +0100 Subject: [PATCH 38/39] docs: unstyled primitive components usage --- docs/src/app/(docs)/concepts/theming/page.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/src/app/(docs)/concepts/theming/page.mdx b/docs/src/app/(docs)/concepts/theming/page.mdx index 8484c1eab5..8aa7178161 100644 --- a/docs/src/app/(docs)/concepts/theming/page.mdx +++ b/docs/src/app/(docs)/concepts/theming/page.mdx @@ -525,6 +525,10 @@ import type { OurFileRouter } from "~/server/uploadthing"; export const UT = generateUploadPrimitives(); ``` +### Usage + +All components accept a child function which allows you to grab any piece of internal state. This includes `files`, `state`, `dropzone` state, and many more. + ### Example of Dropzone The `dropzone` parameter will only be defined from within children of the `Dropzone` component. @@ -532,7 +536,7 @@ export const UT = generateUploadPrimitives(); ```jsx - {({ dropzone, state }) => ( + {({ state }) => (

Drag and drop

From 11f77eb71644e6acbe579c8657c89451f9fa8cb5 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Thu, 24 Oct 2024 18:54:56 +0200 Subject: [PATCH 39/39] more docs --- docs/src/app/(docs)/concepts/theming/page.mdx | 67 ++++++++++++++++--- packages/react/src/components/index.tsx | 2 +- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/src/app/(docs)/concepts/theming/page.mdx b/docs/src/app/(docs)/concepts/theming/page.mdx index 8aa7178161..355394bc6b 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 @@ -510,12 +522,17 @@ type UploadDropzoneProps = { +
+ +# Unstyled Primitive Components -## 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. -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`. -### Setup +## Creating the unstyled components ```ts {{ title: 'src/utils/uploadthing.ts' }} import { generateUploadPrimitives } from "@uploadthing/react"; @@ -525,13 +542,45 @@ import type { OurFileRouter } from "~/server/uploadthing"; export const UT = generateUploadPrimitives(); ``` -### Usage +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. + -All components accept a child function which allows you to grab any piece of internal state. This includes `files`, `state`, `dropzone` state, and many more. + + 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. + + The `dropzone` parameter will only be defined from within children of the + `Dropzone` component. + ```jsx @@ -557,7 +606,7 @@ All components accept a child function which allows you to grab any piece of int ```jsx - {({ state }) => state === "uploading" ? "Uploading" : "Upload file"} + {({ state }) => (state === "uploading" ? "Uploading" : "Upload file")} ``` diff --git a/packages/react/src/components/index.tsx b/packages/react/src/components/index.tsx index b33c66b31c..346c5085d2 100644 --- a/packages/react/src/components/index.tsx +++ b/packages/react/src/components/index.tsx @@ -15,7 +15,7 @@ import { UploadButton } from "./button"; import type { UploadDropzoneProps } from "./dropzone"; import { UploadDropzone } from "./dropzone"; import * as primitives from "./primitive"; -import { RootPrimitiveComponentProps } from "./primitive/root"; +import type { RootPrimitiveComponentProps } from "./primitive/root"; import { Uploader } from "./uploader"; export { UploadButton, UploadDropzone, Uploader };