Skip to content

Commit

Permalink
feat: Add onChange + various feature parity additions/fixes (#886)
Browse files Browse the repository at this point in the history
Co-authored-by: Julius Marminge <[email protected]>
  • Loading branch information
markflorkowski and juliusmarminge committed Sep 6, 2024
1 parent 04c5971 commit 079b434
Show file tree
Hide file tree
Showing 22 changed files with 1,101 additions and 326 deletions.
8 changes: 8 additions & 0 deletions .changeset/giant-candles-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@uploadthing/react": minor
"@uploadthing/solid": minor
"@uploadthing/svelte": minor
"@uploadthing/vue": minor
---

feat: Add `onChange` to `<UploadButton/>` and `<UploadDropzone />`. Deprecate dropzone's `onDrop`
16 changes: 14 additions & 2 deletions .github/workflows/pkg-pr-new.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Get changed packages
id: changed-packages
run: |
echo "::set-output name=changed_packages::$(git diff --name-only main ./packages | awk -F'/' '{print "./packages/" $2}' | sort -u | tr '\n' ' ')"
changed_packages=$(git diff --name-only origin/main origin/${GITHUB_HEAD_REF} ./packages | awk -F'/' '{print "./packages/" $2}' | sort -u | tr '\n' ' ')
# add nuxt IFF it is not already in the list but vue is
if echo "$changed_packages" | grep -q "./packages/vue"; then
if ! echo "$changed_packages" | grep -q "./packages/nuxt"; then
changed_packages="$changed_packages ./packages/nuxt"
fi
fi
echo "changed_packages=$changed_packages" >> $GITHUB_OUTPUT


- name: Setup
uses: ./tooling/gh-actions/setup
Expand All @@ -23,4 +35,4 @@ jobs:

- name: Release
if: steps.changed-packages.outputs.changed_packages != ''
run: pnpx pkg-pr-new --compact publish ${{ steps.changed-packages.outputs.changed_packages }} --template ./examples/minimal*
run: pnpx pkg-pr-new --compact publish ${{ steps.changed-packages.outputs.changed_packages }} --template './examples/minimal-*'
73 changes: 39 additions & 34 deletions docs/src/pages/api-reference/react.mdx

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
"@manypkg/cli": "^0.21.3",
"@playwright/test": "1.45.0",
"@prettier/sync": "^0.5.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/bun": "^1.1.5",
"@types/node": "^20.14.0",
"@uploadthing/eslint-config": "workspace:*",
Expand Down
16 changes: 11 additions & 5 deletions packages/dropzone/src/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ export function createDropzone(_props: DropzoneOptions) {
dispatch({ type: "openDialog" });
input.value = "";
input.click();
} else {
console.warn(
"No input element found for file picker. Please make sure to use the `dropzoneInput` action.",
);
}
};

Expand Down Expand Up @@ -304,21 +308,23 @@ export function createDropzone(_props: DropzoneOptions) {
options,
) => {
inputRef.set(node);
node.setAttribute("type", "file");
node.style.display = "none";
node.setAttribute("type", "file");
node.setAttribute("multiple", String(options.multiple));
node.setAttribute("disabled", String(options.disabled));
node.setAttribute("tabIndex", "-1");
const acceptAttrUnsub = acceptAttr.subscribe((accept) => {
node.setAttribute("accept", accept!);
});
if (!options.disabled) {
node.addEventListener("change", onDropCb);
node.addEventListener("click", onInputElementClick);
}

node.addEventListener("change", onDropCb);
node.addEventListener("click", onInputElementClick);

return {
update(options: DropzoneOptions) {
props.update(($props) => ({ ...$props, ...options }));
node.setAttribute("multiple", String(options.multiple));
node.setAttribute("disabled", String(options.disabled));
},
destroy() {
inputRef.set(null);
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default defineNuxtModule<ModuleOptions>({
{
secret: options.secret,
appId: options.appId,
logLevel: options.logLevel,
logLevel: options.logLevel ?? "info",
},
);

Expand Down
56 changes: 35 additions & 21 deletions packages/react/src/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export type UploadButtonProps<
* @see https://docs.uploadthing.com/theming#content-customisation
*/
content?: ButtonContent;
disabled?: boolean;
};

/** These are some internal stuff we use to test the component and for forcing a state in docs */
Expand Down Expand Up @@ -142,7 +141,7 @@ export function UploadButton<

const uploadFiles = useCallback(
(files: File[]) => {
void startUpload(files, fileRouteInput).catch((e) => {
startUpload(files, fileRouteInput).catch((e) => {
if (e instanceof UploadAbortedError) {
void $props.onUploadAborted?.();
} else {
Expand All @@ -165,25 +164,27 @@ export function UploadButton<
if (!e.target.files) return;
const selectedFiles = Array.from(e.target.files);

$props.onChange?.(selectedFiles);

if (mode === "manual") {
setFiles(selectedFiles);
return;
}

uploadFiles(selectedFiles);
void uploadFiles(selectedFiles);
},
disabled: fileTypes.length === 0,
tabIndex: fileTypes.length === 0 ? -1 : 0,
}),
[fileTypes, mode, multiple, uploadFiles],
[$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 "readying";
if (inputProps.disabled) return "disabled";
if (!inputProps.disabled && !isUploading) return "ready";
return "uploading";
})();
Expand All @@ -198,10 +199,13 @@ export function UploadButton<
let filesToUpload = pastedFiles;
setFiles((prev) => {
filesToUpload = [...prev, ...pastedFiles];

$props.onChange?.(filesToUpload);

return filesToUpload;
});

if (mode === "auto") uploadFiles(files);
if (mode === "auto") void uploadFiles(files);
});

const styleFieldArg = {
Expand All @@ -218,23 +222,28 @@ export function UploadButton<
);
if (customContent) return customContent;

if (state === "readying") return "Loading...";

if (state !== "uploading") {
if (mode === "manual" && files.length > 0) {
return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
switch (state) {
case "readying": {
return "Loading...";
}
case "uploading": {
if (uploadProgress === 100) return <Spinner />;
return (
<span className="z-50">
<span className="block group-hover:hidden">{uploadProgress}%</span>
<Cancel className="hidden size-4 group-hover:block" />
</span>
);
}
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)` : ``}`;
}
return `Choose File${inputProps.multiple ? `(s)` : ``}`;
}

if (uploadProgress === 100) return <Spinner />;

return (
<span className="z-50">
<span className="block group-hover:hidden">{uploadProgress}%</span>
<Cancel className="hidden size-4 group-hover:block" />
</span>
);
};

const renderClearButton = () => (
Expand All @@ -245,6 +254,8 @@ export function UploadButton<
if (fileInputRef.current) {
fileInputRef.current.value = "";
}

$props.onChange?.([]);
}}
className={twMerge(
"h-[1.25rem] cursor-pointer rounded border-none bg-transparent text-gray-500 transition-colors hover:bg-slate-200 hover:text-gray-600",
Expand Down Expand Up @@ -290,6 +301,7 @@ export function UploadButton<
<label
className={twMerge(
"group relative flex h-10 w-36 cursor-pointer items-center justify-center overflow-hidden rounded-md text-white after:transition-[width] after:duration-500 focus-within:ring-2 focus-within:ring-blue-600 focus-within:ring-offset-2",
state === "disabled" && "cursor-not-allowed bg-blue-400",
state === "readying" && "cursor-not-allowed bg-blue-400",
state === "uploading" &&
`bg-blue-400 after:absolute after:left-0 after:h-full after:bg-blue-600 after:content-[''] ${progressWidths[uploadProgress]}`,
Expand All @@ -304,13 +316,15 @@ export function UploadButton<
if (state === "uploading") {
e.preventDefault();
e.stopPropagation();

acRef.current.abort();
acRef.current = new AbortController();
return;
}
if (mode === "manual" && files.length > 0) {
e.preventDefault();
e.stopPropagation();

uploadFiles(files);
}
}}
Expand Down
72 changes: 43 additions & 29 deletions packages/react/src/components/dropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ export type UploadDropzoneProps<
* Callback called when files are dropped or pasted.
*
* @param acceptedFiles - The files that were accepted.
* @deprecated Use `onChange` instead
*/
onDrop?: (acceptedFiles: File[]) => void;
disabled?: boolean;
};

/** These are some internal stuff we use to test the component and for forcing a state in docs */
Expand Down Expand Up @@ -146,8 +146,8 @@ export function UploadDropzone<
);

const uploadFiles = useCallback(
(files: File[]) => {
void startUpload(files, fileRouteInput).catch((e) => {
async (files: File[]) => {
await startUpload(files, fileRouteInput).catch((e) => {
if (e instanceof UploadAbortedError) {
void $props.onUploadAborted?.();
} else {
Expand All @@ -165,11 +165,12 @@ export function UploadDropzone<
const onDrop = useCallback(
(acceptedFiles: File[]) => {
$props.onDrop?.(acceptedFiles);
$props.onChange?.(acceptedFiles);

setFiles(acceptedFiles);

// If mode is auto, start upload immediately
if (mode === "auto") uploadFiles(acceptedFiles);
if (mode === "auto") void uploadFiles(acceptedFiles);
},
[$props, mode, uploadFiles],
);
Expand All @@ -192,7 +193,7 @@ export function UploadDropzone<
$props.__internal_ready ??
($props.__internal_state === "ready" || fileTypes.length > 0);

const onUploadClick = (
const onUploadClick = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
if (state === "uploading") {
Expand All @@ -207,7 +208,7 @@ export function UploadDropzone<
e.preventDefault();
e.stopPropagation();

uploadFiles(files);
await uploadFiles(files);
}
};

Expand All @@ -222,10 +223,15 @@ export function UploadDropzone<
let filesToUpload = pastedFiles;
setFiles((prev) => {
filesToUpload = [...prev, ...pastedFiles];

$props.onChange?.(filesToUpload);

return filesToUpload;
});

if (mode === "auto") uploadFiles(filesToUpload);
$props.onChange?.(filesToUpload);

if (mode === "auto") void uploadFiles(filesToUpload);
};

window.addEventListener("paste", handlePaste);
Expand All @@ -234,27 +240,34 @@ export function UploadDropzone<
};
}, [uploadFiles, $props, appendOnPaste, mode, fileTypes, rootRef, files]);

const getUploadButtonText = (fileTypes: string[]) => {
if (files.length > 0)
return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
if (fileTypes.length === 0) return "Loading...";
return `Choose File${multiple ? `(s)` : ``}`;
};

const getUploadButtonContents = (fileTypes: string[]) => {
if (state !== "uploading") {
return getUploadButtonText(fileTypes);
}
if (uploadProgress === 100) {
return <Spinner />;
}

return (
<span className="z-50">
<span className="block group-hover:hidden">{uploadProgress}%</span>
<Cancel className="hidden size-4 group-hover:block" />
</span>
const getUploadButtonContents = () => {
const customContent = contentFieldToContent(
$props.content?.button,
styleFieldArg,
);
if (customContent) return customContent;

if (state === "readying") {
return "Loading...";
} else if (state === "uploading") {
if (uploadProgress === 100) {
return <Spinner />;
} else {
return (
<span className="z-50">
<span className="block group-hover:hidden">{uploadProgress}%</span>
<Cancel className="hidden size-4 group-hover:block" />
</span>
);
}
} else {
// Default case: "ready" or "disabled" state
if (mode === "manual" && files.length > 0) {
return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
} else {
return `Choose File${multiple ? `(s)` : ``}`;
}
}
};

const styleFieldArg = {
Expand All @@ -267,6 +280,7 @@ export function UploadDropzone<

const state = (() => {
if ($props.__internal_state) return $props.__internal_state;
if (isDisabled) return "disabled";
if (!ready) return "readying";
if (ready && !isUploading) return "ready";

Expand Down Expand Up @@ -344,6 +358,7 @@ export function UploadDropzone<
<button
className={twMerge(
"group relative mt-4 flex h-10 w-36 cursor-pointer items-center justify-center overflow-hidden rounded-md border-none text-base text-white after:transition-[width] after:duration-500 focus-within:ring-2 focus-within:ring-blue-600 focus-within:ring-offset-2",
state === "disabled" && "cursor-not-allowed bg-blue-400",
state === "readying" && "cursor-not-allowed bg-blue-400",
state === "uploading" &&
`bg-blue-400 after:absolute after:left-0 after:h-full after:bg-blue-600 after:content-[''] ${progressWidths[uploadProgress]}`,
Expand All @@ -358,8 +373,7 @@ export function UploadDropzone<
type="button"
disabled={$props.__internal_button_disabled ?? !files.length}
>
{contentFieldToContent($props.content?.button, styleFieldArg) ??
getUploadButtonContents(fileTypes)}
{getUploadButtonContents()}
</button>
</div>
);
Expand Down
Loading

0 comments on commit 079b434

Please sign in to comment.