diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5ebd62c..65f882b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -29,6 +29,53 @@ export const handleError = (err: unknown) => { toast.error((err as Error)?.message || "Unknown error"); }; +/** Delay before removing an unused file input after the picker closes without a selection. */ +const FILE_PICKER_CANCEL_CLEANUP_MS = 500; + +export type OpenFilePickerOptions = { + multiple?: boolean; +}; + +/** + * Opens the native file picker and passes the chosen files to `onSelect`. + * The input is mounted in the document until selection completes; removing it + * immediately after `click()` prevents `change` from firing in some browsers. + */ +export const openFilePicker = ( + onSelect: (files: FileList) => void, + options: OpenFilePickerOptions = {} +) => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = options.multiple ?? false; + + // Keep off-screen but attached, same approach as copyToClipboard(). + input.style.position = "absolute"; + input.style.left = "-999999px"; + + const cleanup = () => { + input.remove(); + window.removeEventListener("focus", onCancelFocus); + }; + + const onCancelFocus = () => { + // Cancel closes the picker without firing change; defer so change can run first. + setTimeout(() => { + if (document.body.contains(input)) cleanup(); + }, FILE_PICKER_CANCEL_CLEANUP_MS); + }; + + input.onchange = (e) => { + cleanup(); + const files = (e.target as HTMLInputElement).files; + if (files?.length) onSelect(files); + }; + + document.body.prepend(input); + window.addEventListener("focus", onCancelFocus, { once: true }); + input.click(); +}; + export const copyToClipboard = async (text: string) => { let textArea: HTMLTextAreaElement | undefined; diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index fea7b84..9b616a5 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -2,7 +2,7 @@ import { FolderPlus, UploadIcon } from "lucide-react"; import Button from "@/components/ui/button"; import { usePutObject } from "./hooks"; import { toast } from "sonner"; -import { handleError } from "@/lib/utils"; +import { handleError, openFilePicker } from "@/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useBucketContext } from "../context"; import { useDisclosure } from "@/hooks/useDisclosure"; @@ -30,16 +30,7 @@ const Actions = ({ prefix }: Props) => { }); const onUploadFile = () => { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - - input.onchange = (e) => { - const files = (e.target as HTMLInputElement).files; - if (!files?.length) { - return; - } - + openFilePicker((files) => { if (files.length > 20) { toast.error("You can only upload up to 20 files at a time"); return; @@ -49,10 +40,7 @@ const Actions = ({ prefix }: Props) => { const key = prefix + file.name; putObject.mutate({ key, file }); } - }; - - input.click(); - input.remove(); + }, { multiple: true }); }; return (