From 94ff87244fa52ef903ffad62eb85ee75682472fc Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:32:01 -0400 Subject: [PATCH 01/13] Initial conversion to 2.0 --- .changeset/upload-solid-2-migration.md | 10 + packages/upload/README.md | 97 ++++++++-- packages/upload/dev/index.tsx | 5 +- packages/upload/package.json | 12 +- packages/upload/src/createDropzone.ts | 18 +- packages/upload/src/createFileUploader.ts | 9 +- packages/upload/src/index.ts | 59 +++--- packages/upload/test/index.test.tsx | 225 +++++++++++++++++++++- pnpm-lock.yaml | 88 +++++++-- 9 files changed, 425 insertions(+), 98 deletions(-) create mode 100644 .changeset/upload-solid-2-migration.md diff --git a/.changeset/upload-solid-2-migration.md b/.changeset/upload-solid-2-migration.md new file mode 100644 index 000000000..55476d08c --- /dev/null +++ b/.changeset/upload-solid-2-migration.md @@ -0,0 +1,10 @@ +--- +"@solid-primitives/upload": minor +--- + +Migrate to Solid.js 2.0 (beta.7) + +- Updated peer dependencies to `solid-js@^2.0.0-beta.7` and `@solidjs/web@^2.0.0-beta.7` +- `isServer` is now imported from `@solidjs/web` (moved out of `solid-js/web`) +- `createDropzone`: replaced `onMount`/`onCleanup` with `onSettled` (returns cleanup function) per Solid 2.0 lifecycle API +- `fileUploader`: replaced the `use:fileUploader` directive (removed in Solid 2.0) with a **ref callback factory** — use `ref={fileUploader(opts)}` instead of `use:fileUploader={opts}` diff --git a/packages/upload/README.md b/packages/upload/README.md index c3a0227af..3ff483a4d 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -16,49 +16,63 @@ Primitive to make uploading files and making dropzones easier. npm install @solid-primitives/upload # or yarn add @solid-primitives/upload +# or +pnpm add @solid-primitives/upload ``` +> **Requires Solid.js 2.0 and `@solidjs/web` 2.0.** + ## How to use it ### [createFileUploader](#createfileuploader) +A reactive primitive that opens the OS file-picker dialog and exposes the selected files as a signal. + ```ts -// single files +// single file const { files, selectFiles } = createFileUploader(); -selectFiles([file] => console.log(file)); +selectFiles(([file]) => console.log(file)); -// multiple files +// multiple files with accept filter const { files, selectFiles } = createFileUploader({ multiple: true, accept: "image/*" }); selectFiles(files => files.forEach(file => console.log(file))); ``` -### use:fileUploader directive +**Returns:** -```ts +| Name | Type | Description | +| ------------- | ----------------------------------------- | ------------------------------------------- | +| `files` | `Accessor` | Reactive list of selected files | +| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback| +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all selected files | + +### [fileUploader](#fileuploader-ref-callback) + +A **ref callback factory** for `` elements (replaces the Solid 1.x `use:fileUploader` directive). + +```tsx const [files, setFiles] = createSignal([]); fs.forEach(f => console.log(f)), setFiles, - }} + })} />; ``` +> **Migration note (Solid 2.0):** The `use:fileUploader` directive syntax has been removed. +> Replace `use:fileUploader={opts}` with `ref={fileUploader(opts)}`. + ### [createDropzone](#createdropzone) -```html -
- Dropzone -
-``` +A reactive primitive for drag-and-drop file targets. -```ts -const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({ +```tsx +const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone({ onDrop: async files => { await doStuff(2); files.forEach(f => console.log(f)); @@ -66,6 +80,57 @@ const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({ onDragStart: files => files.forEach(f => console.log(f)), onDragOver: files => console.log("drag over"), }); + +
+ Dropzone +
+``` + +**Returns:** + +| Name | Type | Description | +| ------------- | ----------------------------- | --------------------------------------------- | +| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | +| `files` | `Accessor` | Reactive list of dropped files | +| `isDragging` | `Accessor` | `true` while a drag is in progress | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all dropped files | + +**DropzoneOptions:** + +| Callback | Type | Description | +| ------------- | -------------- | -------------------------------------- | +| `onDrop` | `UserCallback` | Fired when files are dropped | +| `onDragStart` | `UserCallback` | Fired when a drag starts | +| `onDragEnter` | `UserCallback` | Fired when dragged item enters element | +| `onDragEnd` | `UserCallback` | Fired when drag ends | +| `onDragLeave` | `UserCallback` | Fired when dragged item leaves element | +| `onDragOver` | `UserCallback` | Fired continuously while dragging over | +| `onDrag` | `UserCallback` | Fired on drag events | + +## Types + +```ts +type UploadFile = { + source: string; // blob URL (URL.createObjectURL) + name: string; + size: number; + file: File; +}; + +type UserCallback = (files: UploadFile[]) => void | Promise; + +type FileUploaderOptions = { + accept?: string; + multiple?: boolean; +}; + +type FileUploaderDirective = { + userCallback: UserCallback; + setFiles: Setter; +}; ``` ## Demo diff --git a/packages/upload/dev/index.tsx b/packages/upload/dev/index.tsx index 34065df6e..17f78c8ae 100644 --- a/packages/upload/dev/index.tsx +++ b/packages/upload/dev/index.tsx @@ -5,7 +5,6 @@ import { doStuff } from "../src/helpers.js"; import type { UploadFile } from "../src/types.js"; -fileUploader; const SingleFileUpload: Component = () => { const { files, selectFiles } = createFileUploader(); @@ -99,10 +98,10 @@ const FileUploaderDirective: Component = () => { fs.forEach(f => console.log(f)), setFiles, - }} + })} /> {file =>

{file.name}

}
diff --git a/packages/upload/package.json b/packages/upload/package.json index 404cdcf54..380906c6e 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -1,6 +1,6 @@ { "name": "@solid-primitives/upload", - "version": "0.1.4", + "version": "0.2.0", "description": "Primitives for uploading files.", "author": "Rustam Ashurmatov ", "license": "MIT", @@ -48,14 +48,14 @@ "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, - "dependencies": { - "@solid-primitives/utils": "workspace:^" - }, + "dependencies": {}, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.7", + "solid-js": "^2.0.0-beta.7" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.7", + "solid-js": "2.0.0-beta.7" } } diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 18f17694c..cec49d7d3 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -1,6 +1,5 @@ -import { createSignal, type JSX, onCleanup, onMount } from "solid-js"; -import { isServer } from "solid-js/web"; -import { noop } from "@solid-primitives/utils"; +import { createSignal, type JSX, onSettled } from "solid-js"; +import { isServer } from "@solidjs/web"; import { transformFiles } from "./helpers.js"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; @@ -32,11 +31,11 @@ function createDropzone( ): Dropzone { if (isServer) { return { - setRef: noop, + setRef: () => {}, files: () => [], isDragging: () => false, - removeFile: noop, - clearFiles: noop, + removeFile: () => {}, + clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); @@ -80,10 +79,9 @@ function createDropzone( Promise.resolve(options?.onDrop?.(parsedFiles)); }; - onMount(() => { + onSettled(() => { if (!ref) return; - // TODO: Should event.stopPropagation() or event.preventDefault() in handlers below? ref.addEventListener("dragstart", onDragStart as any); ref.addEventListener("dragenter", onDragEnter as any); ref.addEventListener("dragend", onDragEnd as any); @@ -92,7 +90,7 @@ function createDropzone( ref.addEventListener("drag", onDrag as any); ref.addEventListener("drop", onDrop as any); - onCleanup(() => { + return () => { ref?.removeEventListener("dragstart", onDragStart as any); ref?.removeEventListener("dragenter", onDragEnter as any); ref?.removeEventListener("dragend", onDragEnd as any); @@ -100,7 +98,7 @@ function createDropzone( ref?.removeEventListener("dragover", onDragOver as any); ref?.removeEventListener("drag", onDrag as any); ref?.removeEventListener("drop", onDrop as any); - }); + }; }); const removeFile = (fileName: string) => { diff --git a/packages/upload/src/createFileUploader.ts b/packages/upload/src/createFileUploader.ts index 4edd075c2..dd2fcf1bc 100644 --- a/packages/upload/src/createFileUploader.ts +++ b/packages/upload/src/createFileUploader.ts @@ -1,6 +1,5 @@ import { createSignal, type JSX } from "solid-js"; -import { isServer } from "solid-js/web"; -import { noop } from "@solid-primitives/utils"; +import { isServer } from "@solidjs/web"; import { transformFiles, createInputComponent } from "./helpers.js"; import type { FileUploader, FileUploaderOptions, UploadFile, UserCallback } from "./types.js"; @@ -27,9 +26,9 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { if (isServer) { return { files: () => [], - selectFiles: noop, - removeFile: noop, - clearFiles: noop, + selectFiles: () => {}, + removeFile: () => {}, + clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); diff --git a/packages/upload/src/index.ts b/packages/upload/src/index.ts index 7f2698154..38cb6bc48 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -1,41 +1,36 @@ -import { type JSX, onCleanup, onMount } from "solid-js"; -import { isServer } from "solid-js/web"; +import { onCleanup } from "solid-js"; +import { isServer } from "@solidjs/web"; import { transformFiles } from "./helpers.js"; import { type FileUploaderDirective } from "./types.js"; -declare module "solid-js" { - namespace JSX { - interface Directives { - fileUploader: FileUploaderDirective; +/** + * Ref callback factory for `` elements. + * + * Usage: `` + */ +export const fileUploader = (options: FileUploaderDirective) => { + if (isServer) return (_el: HTMLInputElement) => {}; + + const { userCallback, setFiles } = options; + let element: HTMLInputElement | undefined; + + const onChange = async (event: Event) => { + const target = event.currentTarget as HTMLInputElement; + const parsedFiles = transformFiles(target.files); + setFiles(parsedFiles); + try { + await userCallback(parsedFiles); + } catch (error) { + console.error(error); } - } -} + }; -export const fileUploader = (element: HTMLInputElement, options: () => FileUploaderDirective) => { - if (isServer) { - return; - } - const { userCallback, setFiles } = options(); + onCleanup(() => element?.removeEventListener("change", onChange)); - onMount(() => { - const onChange: JSX.EventHandler = async event => { - const parsedFiles = transformFiles(event.currentTarget.files); - - setFiles(parsedFiles); - - try { - await userCallback(parsedFiles); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - } - return; - }; - - onCleanup(() => element.removeEventListener("change", onChange as any)); - - element.addEventListener("change", onChange as any); - }); + return (el: HTMLInputElement) => { + element = el; + el.addEventListener("change", onChange); + }; }; export { createFileUploader } from "./createFileUploader.js"; diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 6d1c90e17..0ba0d5499 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -1,16 +1,225 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeAll } from "vitest"; import { createRoot } from "solid-js"; -import { createFileUploader } from "../src/index.js"; +import { createFileUploader, createDropzone, fileUploader } from "../src/index.js"; +import { transformFiles } from "../src/helpers.js"; +import type { UploadFile } from "../src/types.js"; + +// ── jsdom polyfills ─────────────────────────────────────────────────────────── + +beforeAll(() => { + // jsdom does not implement createObjectURL + vi.stubGlobal("URL", { createObjectURL: (f: File) => `blob:${f.name}` }); +}); + +// Creates a minimal FileList-alike without DataTransfer (not in jsdom) +function makeFileList(...files: File[]): FileList { + const fl: Record = {}; + files.forEach((f, i) => (fl[i] = f)); + fl.length = files.length; + return fl as unknown as FileList; +} + +function makeFile(name = "test.png", size = 64, type = "image/png"): File { + return new File(["x".repeat(size)], name, { type }); +} + +// ── transformFiles ──────────────────────────────────────────────────────────── + +describe("transformFiles", () => { + it("returns empty array for null input", () => { + expect(transformFiles(null)).toEqual([]); + }); + + it("converts a FileList to UploadFile array", () => { + const file = makeFile("hello.png", 512); + const result = transformFiles(makeFileList(file)); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("hello.png"); + expect(result[0]!.size).toBe(512); + expect(result[0]!.file).toBe(file); + expect(typeof result[0]!.source).toBe("string"); + }); + + it("converts multiple files", () => { + const result = transformFiles(makeFileList(makeFile("a.png"), makeFile("b.jpg"))); + expect(result).toHaveLength(2); + expect(result.map(f => f.name)).toEqual(["a.png", "b.jpg"]); + }); +}); + +// ── createFileUploader ──────────────────────────────────────────────────────── describe("createFileUploader", () => { - it("file upload", () => { - createRoot(dispose => { - const { files: file } = createFileUploader(); - const { files } = createFileUploader({ multiple: true }); + it("initialises with empty file list", () => { + const { files, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + expect(files()).toEqual([]); + dispose(); + }); + + it("initialises with empty file list (multiple mode)", () => { + const { files, dispose } = createRoot(dispose => ({ + ...createFileUploader({ multiple: true }), + dispose, + })); + expect(files()).toEqual([]); + dispose(); + }); + + it("removeFile filters by name", () => { + // In Solid 2.0, signal writes must occur outside owned scopes. + // We pull the API out of createRoot, then call it at the test level. + const { files, removeFile, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + expect(files()).toEqual([]); + removeFile("nonexistent.png"); // no-op, should not throw + expect(files()).toEqual([]); + dispose(); + }); + + it("clearFiles empties the list", () => { + const { files, clearFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + clearFiles(); + expect(files()).toEqual([]); + dispose(); + }); + + it("selectFiles triggers a file input click", () => { + const { selectFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + + const clickSpy = vi.fn(); + const origCreate = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") vi.spyOn(el as HTMLInputElement, "click").mockImplementation(clickSpy); + return el; + }); + + selectFiles(() => {}); + expect(clickSpy).toHaveBeenCalledOnce(); + + vi.restoreAllMocks(); + dispose(); + }); +}); - expect(file()).toEqual([]); - expect(files()).toEqual([]); +// ── createDropzone ──────────────────────────────────────────────────────────── + +describe("createDropzone", () => { + it("initialises with empty files and isDragging=false", () => { + const { files, isDragging, dispose } = createRoot(dispose => ({ + ...createDropzone(), + dispose, + })); + expect(files()).toEqual([]); + expect(isDragging()).toBe(false); + dispose(); + }); + + it("exposes setRef, removeFile, clearFiles", () => { + const { setRef, removeFile, clearFiles, dispose } = createRoot(dispose => ({ + ...createDropzone(), + dispose, + })); + expect(typeof setRef).toBe("function"); + expect(typeof removeFile).toBe("function"); + expect(typeof clearFiles).toBe("function"); + dispose(); + }); + + it("clearFiles empties the list", () => { + const { files, clearFiles, dispose } = createRoot(dispose => ({ + ...createDropzone(), + dispose, + })); + clearFiles(); + expect(files()).toEqual([]); + dispose(); + }); + + it("accepts DropzoneOptions callbacks without throwing", () => { + expect(() => { + const dispose = createRoot(dispose => { + createDropzone({ + onDrop: vi.fn(), + onDragStart: vi.fn(), + onDragEnter: vi.fn(), + onDragEnd: vi.fn(), + onDragLeave: vi.fn(), + onDragOver: vi.fn(), + onDrag: vi.fn(), + }); + return dispose; + }); + dispose(); + }).not.toThrow(); + }); +}); + +// ── fileUploader ref callback ───────────────────────────────────────────────── + +describe("fileUploader", () => { + it("returns a function (ref callback)", () => { + const ref = createRoot(dispose => { + const r = fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }); + dispose(); + return r; + }); + expect(typeof ref).toBe("function"); + }); + + it("attaches a change listener to the element", () => { + const input = document.createElement("input"); + input.type = "file"; + const addSpy = vi.spyOn(input, "addEventListener"); + + const ref = createRoot(dispose => { + const r = fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }); + dispose(); + return r; + }); + + ref(input); + expect(addSpy).toHaveBeenCalledWith("change", expect.any(Function)); + }); + + it("calls setFiles and userCallback on change event", async () => { + const input = document.createElement("input"); + input.type = "file"; + const setFiles = vi.fn(); + const userCallback = vi.fn(); + + const ref = createRoot(dispose => { + const r = fileUploader({ userCallback, setFiles }); dispose(); + return r; }); + + ref(input); + + const file = makeFile("upload.png"); + Object.defineProperty(input, "files", { value: makeFileList(file), configurable: true }); + + const event = new Event("change"); + Object.defineProperty(event, "currentTarget", { value: input, configurable: true }); + input.dispatchEvent(event); + + await Promise.resolve(); + + expect(setFiles).toHaveBeenCalledOnce(); + const uploaded: UploadFile[] = setFiles.mock.calls[0]![0]; + expect(uploaded).toHaveLength(1); + expect(uploaded[0]!.name).toBe("upload.png"); + expect(userCallback).toHaveBeenCalledOnce(); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecadfdb95..5b7dd9a54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -964,9 +964,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.7 + version: 2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.7 + version: 2.0.0-beta.7 packages/utils: devDependencies: @@ -1048,10 +1051,10 @@ importers: version: link:../packages/utils '@solidjs/meta': specifier: ^0.29.3 - version: 0.29.4(solid-js@1.9.7) + version: 0.29.4(solid-js@2.0.0-beta.7) '@solidjs/router': specifier: ^0.13.1 - version: 0.13.6(solid-js@1.9.7) + version: 0.13.6(solid-js@2.0.0-beta.7) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -1078,13 +1081,13 @@ importers: version: 1.77.8 solid-dismiss: specifier: ^1.7.121 - version: 1.8.2(solid-js@1.9.7) + version: 1.8.2(solid-js@2.0.0-beta.7) solid-icons: specifier: ^1.1.0 - version: 1.1.0(solid-js@1.9.7) + version: 1.1.0(solid-js@2.0.0-beta.7) solid-tippy: specifier: ^0.2.1 - version: 0.2.1(solid-js@1.9.7)(tippy.js@6.3.7) + version: 0.2.1(solid-js@2.0.0-beta.7)(tippy.js@6.3.7) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -2042,6 +2045,7 @@ packages: '@graphql-tools/prisma-loader@8.0.4': resolution: {integrity: sha512-hqKPlw8bOu/GRqtYr0+dINAI13HinTVYBDqhwGAPIFmLr5s+qKskzgCiwbsckdrb5LWVFmVZc+UXn80OGiyBzg==} engines: {node: '>=16.0.0'} + deprecated: 'This package was intended to be used with an older versions of Prisma.\nThe newer versions of Prisma has a different approach to GraphQL integration.\nTherefore, this package is no longer needed and has been deprecated and removed.\nLearn more: https://www.prisma.io/graphql' peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 @@ -2587,11 +2591,20 @@ packages: peerDependencies: solid-js: ^1.5.3 + '@solidjs/signals@2.0.0-beta.7': + resolution: {integrity: sha512-SgK6oQlQZofz82LiEJ2RzT3sbs1lWTqFEtLoWjLsUo/dk1v9EoIFpJJlmvgkXvNugASWG+l1yOHa1a8lPamxug==} + '@solidjs/start@1.1.4': resolution: {integrity: sha512-ma1TBYqoTju87tkqrHExMReM5Z/+DTXSmi30CCTavtwuR73Bsn4rVGqm528p4sL2koRMfAuBMkrhuttjzhL68g==} peerDependencies: vinxi: ^0.5.3 + '@solidjs/web@2.0.0-beta.7': + resolution: {integrity: sha512-m5VjmDBufrOX0ZKGbhvwkT0CPK0TbMxDbxVPDB1PH2evGbWXQZcUlrpFM1N8RBO5md3aR/T1PgMfnOjleJbrRg==} + peerDependencies: + '@solidjs/signals': ^2.0.0-beta.7 + solid-js: ^2.0.0-beta.7 + '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} @@ -3513,6 +3526,7 @@ packages: dax-sh@0.43.2: resolution: {integrity: sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg==} + deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' db0@0.3.2: resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} @@ -4164,11 +4178,12 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -5892,10 +5907,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + serve-placeholder@2.0.2: resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} @@ -6001,6 +6026,9 @@ packages: solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + solid-js@2.0.0-beta.7: + resolution: {integrity: sha512-7JHs+BhLeZXoU+u9dG+eKnyxxfZyGpOuJEBbN/1XbHKO/WhxecdplOAurlg/YDllNWPhsbXqmLR1H2paqSu62g==} + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -6195,6 +6223,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -6710,6 +6739,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -8576,18 +8606,20 @@ snapshots: dependencies: solid-js: 1.9.7 - '@solidjs/meta@0.29.4(solid-js@1.9.7)': + '@solidjs/meta@0.29.4(solid-js@2.0.0-beta.7)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 - '@solidjs/router@0.13.6(solid-js@1.9.7)': + '@solidjs/router@0.13.6(solid-js@2.0.0-beta.7)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 '@solidjs/router@0.8.4(solid-js@1.9.7)': dependencies: solid-js: 1.9.7 + '@solidjs/signals@2.0.0-beta.7': {} + '@solidjs/start@1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': dependencies: '@tanstack/server-functions-plugin': 1.121.0(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) @@ -8611,6 +8643,13 @@ snapshots: - supports-color - vite + '@solidjs/web@2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7)': + dependencies: + '@solidjs/signals': 2.0.0-beta.7 + seroval: 1.3.2 + seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js: 2.0.0-beta.7 + '@speed-highlight/core@1.2.7': {} '@supabase/auth-js@2.67.3': @@ -12441,8 +12480,14 @@ snapshots: dependencies: seroval: 1.3.2 + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + seroval@1.3.2: {} + seroval@1.5.2: {} + serve-placeholder@2.0.2: dependencies: defu: 6.1.4 @@ -12557,13 +12602,13 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - solid-dismiss@1.8.2(solid-js@1.9.7): + solid-dismiss@1.8.2(solid-js@2.0.0-beta.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 - solid-icons@1.1.0(solid-js@1.9.7): + solid-icons@1.1.0(solid-js@2.0.0-beta.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 solid-js@1.9.7: dependencies: @@ -12571,6 +12616,13 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js@2.0.0-beta.7: + dependencies: + '@solidjs/signals': 2.0.0-beta.7 + csstype: 3.1.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-refresh@0.6.3(solid-js@1.9.7): dependencies: '@babel/generator': 7.27.5 @@ -12580,9 +12632,9 @@ snapshots: transitivePeerDependencies: - supports-color - solid-tippy@0.2.1(solid-js@1.9.7)(tippy.js@6.3.7): + solid-tippy@0.2.1(solid-js@2.0.0-beta.7)(tippy.js@6.3.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 tippy.js: 6.3.7 solid-transition-group@0.2.3(solid-js@1.9.7): From 5ea5b7590cbf7bb3ab43d823155850aa11e83b90 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:46:29 -0400 Subject: [PATCH 02/13] Added better error handling and minor package cleanup --- packages/upload/README.md | 33 +++-- packages/upload/src/createDropzone.ts | 71 ++++++----- packages/upload/src/createFileUploader.ts | 15 ++- packages/upload/src/fileUploader.ts | 41 ++++++ packages/upload/src/index.ts | 36 +----- packages/upload/src/types.ts | 4 +- packages/upload/test/index.test.tsx | 144 +++++++++++++++++++--- 7 files changed, 241 insertions(+), 103 deletions(-) create mode 100644 packages/upload/src/fileUploader.ts diff --git a/packages/upload/README.md b/packages/upload/README.md index 3ff483a4d..2f8369934 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -40,12 +40,13 @@ selectFiles(files => files.forEach(file => console.log(file))); **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------------------- | ------------------------------------------- | -| `files` | `Accessor` | Reactive list of selected files | -| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback| -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all selected files | +| Name | Type | Description | +| ------------- | ----------------------------------------- | ------------------------------------------------------------------ | +| `files` | `Accessor` | Reactive list of selected files | +| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | +| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all selected files | ### [fileUploader](#fileuploader-ref-callback) @@ -53,6 +54,7 @@ A **ref callback factory** for `` elements (replaces the Soli ```tsx const [files, setFiles] = createSignal([]); +const [uploadError, setUploadError] = createSignal(null); ([]); ref={fileUploader({ userCallback: fs => fs.forEach(f => console.log(f)), setFiles, + onError: err => setUploadError(err), })} />; ``` +If `onError` is omitted, a rejection from `userCallback` propagates as an unhandled promise rejection. + > **Migration note (Solid 2.0):** The `use:fileUploader` directive syntax has been removed. > Replace `use:fileUploader={opts}` with `ref={fileUploader(opts)}`. @@ -90,13 +95,14 @@ const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone( **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------- | --------------------------------------------- | -| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | -| `files` | `Accessor` | Reactive list of dropped files | -| `isDragging` | `Accessor` | `true` while a drag is in progress | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all dropped files | +| Name | Type | Description | +| ------------- | ----------------------------- | ------------------------------------------------------------------ | +| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | +| `files` | `Accessor` | Reactive list of dropped files | +| `error` | `Accessor` | Error thrown by the last drag callback; `null` if none | +| `isDragging` | `Accessor` | `true` while a drag is in progress | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all dropped files | **DropzoneOptions:** @@ -130,6 +136,7 @@ type FileUploaderOptions = { type FileUploaderDirective = { userCallback: UserCallback; setFiles: Setter; + onError?: (error: unknown) => void; }; ``` diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index cec49d7d3..6b568519a 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -1,4 +1,4 @@ -import { createSignal, type JSX, onSettled } from "solid-js"; +import { createSignal, type JSX, getOwner, onCleanup, runWithOwner } from "solid-js"; import { isServer } from "@solidjs/web"; import { transformFiles } from "./helpers.js"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; @@ -8,19 +8,18 @@ import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; * * @returns `setRef` * @returns `files` + * @returns `error` - Reactive error from the last drag callback, cleared on next drop * @returns `isDragging` * @returns `removeFile` * @returns `clearFiles` * * @example * ```ts - * // run async user callback - * const { setRef: dropzoneRef1, files: droppedFiles1 } = createDropzone({ + * const { setRef: dropzoneRef, files: droppedFiles, error } = createDropzone({ * onDrop: async files => { * await doStuff(2); * files.forEach(f => console.log(f)); * }, - * onDragStart: files => console.log("drag start") * onDragStart: files => files.forEach(f => console.log(f)), * onDragOver: files => console.log("drag over") * }); @@ -33,55 +32,62 @@ function createDropzone( return { setRef: () => {}, files: () => [], + error: () => null, isDragging: () => false, removeFile: () => {}, clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); + const [error, setError] = createSignal(null); const [isDragging, setIsDragging] = createSignal(false); - let ref: T | undefined = undefined; + // Capture owner in the Phase-1 (owned) scope so cleanup can be registered later + const owner = getOwner(); - const setRef = (r: T) => { - ref = r; + const runCallback = async ( + callback: ((files: UploadFile[]) => void | Promise) | undefined, + parsedFiles: UploadFile[], + ) => { + try { + await callback?.(parsedFiles); + } catch (err) { + setError(err); + } }; const onDragStart: JSX.EventHandler = event => { setIsDragging(true); - Promise.resolve(options?.onDragStart?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragStart, transformFiles(event.dataTransfer?.files || null)); }; const onDragEnd: JSX.EventHandler = event => { setIsDragging(false); - Promise.resolve(options?.onDragEnd?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragEnd, transformFiles(event.dataTransfer?.files || null)); }; - const onDragEnter: JSX.EventHandler = event => { - Promise.resolve(options?.onDragEnter?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragEnter, transformFiles(event.dataTransfer?.files || null)); }; const onDragLeave: JSX.EventHandler = event => { - Promise.resolve(options?.onDragLeave?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragLeave, transformFiles(event.dataTransfer?.files || null)); }; const onDragOver: JSX.EventHandler = event => { event.preventDefault(); - Promise.resolve(options?.onDragOver?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragOver, transformFiles(event.dataTransfer?.files || null)); }; const onDrag: JSX.EventHandler = event => { - Promise.resolve(options?.onDrag?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDrag, transformFiles(event.dataTransfer?.files || null)); }; - const onDrop: JSX.EventHandler = event => { event.preventDefault(); - const parsedFiles = transformFiles(event.dataTransfer?.files || null); setFiles(parsedFiles); - - Promise.resolve(options?.onDrop?.(parsedFiles)); + setError(null); + void runCallback(options?.onDrop, parsedFiles); }; - onSettled(() => { - if (!ref) return; - + // setRef is the Phase-2 ref callback: called synchronously when the element is created. + // Listeners are attached immediately; cleanup is registered back in the owner scope. + const setRef = (ref: T) => { ref.addEventListener("dragstart", onDragStart as any); ref.addEventListener("dragenter", onDragEnter as any); ref.addEventListener("dragend", onDragEnd as any); @@ -90,16 +96,18 @@ function createDropzone( ref.addEventListener("drag", onDrag as any); ref.addEventListener("drop", onDrop as any); - return () => { - ref?.removeEventListener("dragstart", onDragStart as any); - ref?.removeEventListener("dragenter", onDragEnter as any); - ref?.removeEventListener("dragend", onDragEnd as any); - ref?.removeEventListener("dragleave", onDragLeave as any); - ref?.removeEventListener("dragover", onDragOver as any); - ref?.removeEventListener("drag", onDrag as any); - ref?.removeEventListener("drop", onDrop as any); - }; - }); + runWithOwner(owner, () => { + onCleanup(() => { + ref.removeEventListener("dragstart", onDragStart as any); + ref.removeEventListener("dragenter", onDragEnter as any); + ref.removeEventListener("dragend", onDragEnd as any); + ref.removeEventListener("dragleave", onDragLeave as any); + ref.removeEventListener("dragover", onDragOver as any); + ref.removeEventListener("drag", onDrag as any); + ref.removeEventListener("drop", onDrop as any); + }); + }); + }; const removeFile = (fileName: string) => { setFiles(prev => prev.filter(f => f.name !== fileName)); @@ -112,6 +120,7 @@ function createDropzone( return { setRef, files, + error, isDragging, removeFile, clearFiles, diff --git a/packages/upload/src/createFileUploader.ts b/packages/upload/src/createFileUploader.ts index dd2fcf1bc..e2c58a4de 100644 --- a/packages/upload/src/createFileUploader.ts +++ b/packages/upload/src/createFileUploader.ts @@ -7,6 +7,7 @@ import type { FileUploader, FileUploaderOptions, UploadFile, UserCallback } from * Primitive to make uploading files easier. * * @returns `files` + * @returns `error` - Reactive error from the last `selectFiles` callback, cleared on next selection * @returns `selectFiles` - Open file picker, set files and run user callback * @returns `removeFile` * @returns `clearFiles` @@ -14,11 +15,11 @@ import type { FileUploader, FileUploaderOptions, UploadFile, UserCallback } from * @example * ```ts * // multiple files - * const {files, selectFiles} = createFileUploader({ multiple: true, accept: "image/*" }); + * const { files, error, selectFiles } = createFileUploader({ multiple: true, accept: "image/*" }); * selectFiles(files => files.forEach(file => console.log(file))); * * // single file - * const {file, selectFile} = createFileUploader(); + * const { files, error, selectFiles } = createFileUploader(); * selectFiles(([{ source, name, size, file }]) => console.log({ source, name, size, file })); * ``` */ @@ -26,12 +27,14 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { if (isServer) { return { files: () => [], + error: () => null, selectFiles: () => {}, removeFile: () => {}, clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); + const [error, setError] = createSignal(null); let userCallback: UserCallback = () => {}; @@ -50,14 +53,13 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { target.remove(); setFiles(parsedFiles); + setError(null); try { await userCallback(parsedFiles); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + } catch (err) { + setError(err); } - return; }; const selectFiles = (callback?: UserCallback) => { @@ -81,6 +83,7 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { return { files, + error, selectFiles, removeFile, clearFiles, diff --git a/packages/upload/src/fileUploader.ts b/packages/upload/src/fileUploader.ts new file mode 100644 index 000000000..db5e47924 --- /dev/null +++ b/packages/upload/src/fileUploader.ts @@ -0,0 +1,41 @@ +import { onCleanup } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { transformFiles } from "./helpers.js"; +import { type FileUploaderDirective } from "./types.js"; + +/** + * Ref callback factory for `` elements. + * + * If `onError` is provided it is called with the thrown value when `userCallback` rejects; + * otherwise the rejection propagates as an unhandled promise rejection. + * + * Usage: `` + */ +export const fileUploader = (options: FileUploaderDirective) => { + if (isServer) return (_el: HTMLInputElement) => {}; + + const { userCallback, setFiles, onError } = options; + let element: HTMLInputElement | undefined; + + const onChange = async (event: Event) => { + const target = event.currentTarget as HTMLInputElement; + const parsedFiles = transformFiles(target.files); + setFiles(parsedFiles); + try { + await userCallback(parsedFiles); + } catch (err) { + if (onError) { + onError(err); + } else { + throw err; + } + } + }; + + onCleanup(() => element?.removeEventListener("change", onChange)); + + return (el: HTMLInputElement) => { + element = el; + el.addEventListener("change", onChange); + }; +}; diff --git a/packages/upload/src/index.ts b/packages/upload/src/index.ts index 38cb6bc48..0c9d1da44 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -1,38 +1,4 @@ -import { onCleanup } from "solid-js"; -import { isServer } from "@solidjs/web"; -import { transformFiles } from "./helpers.js"; -import { type FileUploaderDirective } from "./types.js"; - -/** - * Ref callback factory for `` elements. - * - * Usage: `` - */ -export const fileUploader = (options: FileUploaderDirective) => { - if (isServer) return (_el: HTMLInputElement) => {}; - - const { userCallback, setFiles } = options; - let element: HTMLInputElement | undefined; - - const onChange = async (event: Event) => { - const target = event.currentTarget as HTMLInputElement; - const parsedFiles = transformFiles(target.files); - setFiles(parsedFiles); - try { - await userCallback(parsedFiles); - } catch (error) { - console.error(error); - } - }; - - onCleanup(() => element?.removeEventListener("change", onChange)); - - return (el: HTMLInputElement) => { - element = el; - el.addEventListener("change", onChange); - }; -}; - export { createFileUploader } from "./createFileUploader.js"; export { createDropzone } from "./createDropzone.js"; +export { fileUploader } from "./fileUploader.js"; export * from "./types.js"; diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index a1b235f29..aa2475c6d 100644 --- a/packages/upload/src/types.ts +++ b/packages/upload/src/types.ts @@ -20,10 +20,10 @@ export type FileUploaderOptions = { }; export type UserCallback = (files: UploadFile[]) => void | Promise; -export type UserCallback2 = () => void | Promise; export interface FileUploader { files: Accessor; + error: Accessor; selectFiles: (callback: (files: UploadFile[]) => void | Promise) => void; removeFile: (fileName: string) => void; clearFiles: () => void; @@ -32,11 +32,13 @@ export interface FileUploader { export type FileUploaderDirective = { userCallback: (files: UploadFile[]) => void | Promise; setFiles: Setter; + onError?: (error: unknown) => void; }; export interface Dropzone { setRef: (ref: T) => void; files: Accessor; + error: Accessor; isDragging: Accessor; removeFile: (fileName: string) => void; clearFiles: () => void; diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 0ba0d5499..826246e1c 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -23,6 +23,14 @@ function makeFile(name = "test.png", size = 64, type = "image/png"): File { return new File(["x".repeat(size)], name, { type }); } +// Dispatch a change event with the given files on an input element +function dispatchChange(input: HTMLInputElement, files: FileList) { + Object.defineProperty(input, "files", { value: files, configurable: true }); + const event = new Event("change"); + Object.defineProperty(event, "currentTarget", { value: input, configurable: true }); + input.dispatchEvent(event); +} + // ── transformFiles ──────────────────────────────────────────────────────────── describe("transformFiles", () => { @@ -50,12 +58,13 @@ describe("transformFiles", () => { // ── createFileUploader ──────────────────────────────────────────────────────── describe("createFileUploader", () => { - it("initialises with empty file list", () => { - const { files, dispose } = createRoot(dispose => ({ + it("initialises with empty file list and no error", () => { + const { files, error, dispose } = createRoot(dispose => ({ ...createFileUploader(), dispose, })); expect(files()).toEqual([]); + expect(error()).toBeNull(); dispose(); }); @@ -68,15 +77,12 @@ describe("createFileUploader", () => { dispose(); }); - it("removeFile filters by name", () => { - // In Solid 2.0, signal writes must occur outside owned scopes. - // We pull the API out of createRoot, then call it at the test level. + it("removeFile is a no-op on an empty list", () => { const { files, removeFile, dispose } = createRoot(dispose => ({ ...createFileUploader(), dispose, })); - expect(files()).toEqual([]); - removeFile("nonexistent.png"); // no-op, should not throw + removeFile("nonexistent.png"); expect(files()).toEqual([]); dispose(); }); @@ -111,17 +117,79 @@ describe("createFileUploader", () => { vi.restoreAllMocks(); dispose(); }); + + it("error() is null when callback succeeds", async () => { + const { error, selectFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + + const origCreate = document.createElement.bind(document); + let createdInput: HTMLInputElement | undefined; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") { + createdInput = el as HTMLInputElement; + vi.spyOn(createdInput, "click").mockImplementation(() => { + dispatchChange(createdInput!, makeFileList(makeFile())); + }); + } + return el; + }); + + selectFiles(async () => { + /* success */ + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(error()).toBeNull(); + vi.restoreAllMocks(); + dispose(); + }); + + it("error() captures a thrown callback error", async () => { + const boom = new Error("upload failed"); + const { error, selectFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + + const origCreate = document.createElement.bind(document); + let createdInput: HTMLInputElement | undefined; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") { + createdInput = el as HTMLInputElement; + vi.spyOn(createdInput, "click").mockImplementation(() => { + dispatchChange(createdInput!, makeFileList(makeFile())); + }); + } + return el; + }); + + selectFiles(async () => { + throw boom; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(error()).toBe(boom); + vi.restoreAllMocks(); + dispose(); + }); }); // ── createDropzone ──────────────────────────────────────────────────────────── describe("createDropzone", () => { - it("initialises with empty files and isDragging=false", () => { - const { files, isDragging, dispose } = createRoot(dispose => ({ + it("initialises with empty files, no error, and isDragging=false", () => { + const { files, error, isDragging, dispose } = createRoot(dispose => ({ ...createDropzone(), dispose, })); expect(files()).toEqual([]); + expect(error()).toBeNull(); expect(isDragging()).toBe(false); dispose(); }); @@ -164,6 +232,31 @@ describe("createDropzone", () => { dispose(); }).not.toThrow(); }); + + it("error() captures a thrown onDrop callback", async () => { + const boom = new Error("drop failed"); + const { error, setRef, dispose } = createRoot(dispose => ({ + ...createDropzone({ onDrop: async () => { throw boom; } }), + dispose, + })); + + const div = document.createElement("div"); + setRef(div); + + // jsdom does not implement DragEvent — dispatch a plain Event with stubbed dataTransfer + const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, "dataTransfer", { + value: { files: makeFileList(makeFile()) }, + configurable: true, + }); + div.dispatchEvent(dropEvent); + + await Promise.resolve(); + await Promise.resolve(); + + expect(error()).toBe(boom); + dispose(); + }); }); // ── fileUploader ref callback ───────────────────────────────────────────────── @@ -206,14 +299,7 @@ describe("fileUploader", () => { }); ref(input); - - const file = makeFile("upload.png"); - Object.defineProperty(input, "files", { value: makeFileList(file), configurable: true }); - - const event = new Event("change"); - Object.defineProperty(event, "currentTarget", { value: input, configurable: true }); - input.dispatchEvent(event); - + dispatchChange(input, makeFileList(makeFile("upload.png"))); await Promise.resolve(); expect(setFiles).toHaveBeenCalledOnce(); @@ -222,4 +308,28 @@ describe("fileUploader", () => { expect(uploaded[0]!.name).toBe("upload.png"); expect(userCallback).toHaveBeenCalledOnce(); }); + + it("calls onError when userCallback throws", async () => { + const boom = new Error("cb failed"); + const input = document.createElement("input"); + input.type = "file"; + const onError = vi.fn(); + + const ref = createRoot(dispose => { + const r = fileUploader({ + userCallback: async () => { throw boom; }, + setFiles: vi.fn(), + onError, + }); + dispose(); + return r; + }); + + ref(input); + dispatchChange(input, makeFileList(makeFile())); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith(boom); + }); }); From 3af985e676f3da435ee4d1b827d524df5c10b59f Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:46:40 -0400 Subject: [PATCH 03/13] Ran formatter --- packages/audio/dev/index.tsx | 4 +-- packages/geolocation/dev/client.tsx | 2 +- packages/upload/README.md | 41 ++++++++++++++++------------- packages/upload/dev/index.tsx | 1 - packages/upload/test/index.test.tsx | 10 +++++-- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/audio/dev/index.tsx b/packages/audio/dev/index.tsx index b9dea4595..6df2ad096 100644 --- a/packages/audio/dev/index.tsx +++ b/packages/audio/dev/index.tsx @@ -76,7 +76,7 @@ const App: Component = () => {
-
(ref = el)} /> +
(ref = el)} />
{location()?.latitude}, {location()?.longitude}
diff --git a/packages/upload/README.md b/packages/upload/README.md index 2f8369934..1609fd0ad 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -40,13 +40,13 @@ selectFiles(files => files.forEach(file => console.log(file))); **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------------------- | ------------------------------------------------------------------ | -| `files` | `Accessor` | Reactive list of selected files | -| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | -| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all selected files | +| Name | Type | Description | +| ------------- | ----------------------------------- | --------------------------------------------------------------- | +| `files` | `Accessor` | Reactive list of selected files | +| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | +| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all selected files | ### [fileUploader](#fileuploader-ref-callback) @@ -77,7 +77,11 @@ If `onError` is omitted, a rejection from `userCallback` propagates as an unhand A reactive primitive for drag-and-drop file targets. ```tsx -const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone({ +const { + setRef: dropzoneRef, + files: droppedFiles, + isDragging, +} = createDropzone({ onDrop: async files => { await doStuff(2); files.forEach(f => console.log(f)); @@ -88,21 +92,22 @@ const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone(
+ style={{ width: "100px", height: "100px", background: isDragging() ? "green" : "red" }} +> Dropzone -
+
; ``` **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------- | ------------------------------------------------------------------ | -| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | -| `files` | `Accessor` | Reactive list of dropped files | -| `error` | `Accessor` | Error thrown by the last drag callback; `null` if none | -| `isDragging` | `Accessor` | `true` while a drag is in progress | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all dropped files | +| Name | Type | Description | +| ------------ | ---------------------------- | ------------------------------------------------------ | +| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | +| `files` | `Accessor` | Reactive list of dropped files | +| `error` | `Accessor` | Error thrown by the last drag callback; `null` if none | +| `isDragging` | `Accessor` | `true` while a drag is in progress | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all dropped files | **DropzoneOptions:** diff --git a/packages/upload/dev/index.tsx b/packages/upload/dev/index.tsx index 17f78c8ae..cb8b02f54 100644 --- a/packages/upload/dev/index.tsx +++ b/packages/upload/dev/index.tsx @@ -5,7 +5,6 @@ import { doStuff } from "../src/helpers.js"; import type { UploadFile } from "../src/types.js"; - const SingleFileUpload: Component = () => { const { files, selectFiles } = createFileUploader(); const { files: filesAsync, selectFiles: selectFilesAsync } = createFileUploader(); diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 826246e1c..cac65687d 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -236,7 +236,11 @@ describe("createDropzone", () => { it("error() captures a thrown onDrop callback", async () => { const boom = new Error("drop failed"); const { error, setRef, dispose } = createRoot(dispose => ({ - ...createDropzone({ onDrop: async () => { throw boom; } }), + ...createDropzone({ + onDrop: async () => { + throw boom; + }, + }), dispose, })); @@ -317,7 +321,9 @@ describe("fileUploader", () => { const ref = createRoot(dispose => { const r = fileUploader({ - userCallback: async () => { throw boom; }, + userCallback: async () => { + throw boom; + }, setFiles: vi.fn(), onError, }); From 79ff74a016abd68811b2bc04ea481821b0202141 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 2 May 2026 18:11:23 -0400 Subject: [PATCH 04/13] Bump to beta 10 --- packages/upload/package.json | 8 +- packages/upload/tsconfig.json | 6 +- pnpm-lock.yaml | 147 ++++++++++++++++++---------------- 3 files changed, 83 insertions(+), 78 deletions(-) diff --git a/packages/upload/package.json b/packages/upload/package.json index 380906c6e..de0d89fba 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -50,12 +50,12 @@ }, "dependencies": {}, "peerDependencies": { - "@solidjs/web": "^2.0.0-beta.7", - "solid-js": "^2.0.0-beta.7" + "@solidjs/web": "^2.0.0-beta.10", + "solid-js": "^2.0.0-beta.10" }, "typesVersions": {}, "devDependencies": { - "@solidjs/web": "2.0.0-beta.7", - "solid-js": "2.0.0-beta.7" + "@solidjs/web": "2.0.0-beta.10", + "solid-js": "2.0.0-beta.10" } } diff --git a/packages/upload/tsconfig.json b/packages/upload/tsconfig.json index dc1970e16..38c71ce71 100644 --- a/packages/upload/tsconfig.json +++ b/packages/upload/tsconfig.json @@ -5,11 +5,7 @@ "outDir": "dist", "rootDir": "src" }, - "references": [ - { - "path": "../utils" - } - ], + "references": [], "include": [ "src" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b7dd9a54..39eb97e95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 1.0.1 '@solidjs/start': specifier: ^1.1.4 - version: 1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) + version: 1.1.4(solid-js@2.0.0-beta.10)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) '@types/jsdom': specifier: ^21.1.7 version: 21.1.7 @@ -34,7 +34,7 @@ importers: version: 0.25.5 esbuild-plugin-solid: specifier: ^0.6.0 - version: 0.6.0(esbuild@0.25.5)(solid-js@1.9.7) + version: 0.6.0(esbuild@0.25.5)(solid-js@2.0.0-beta.10) eslint: specifier: ^9.28.0 version: 9.28.0(jiti@2.4.2) @@ -69,8 +69,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -82,7 +82,7 @@ importers: version: 6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0) vite-plugin-solid: specifier: ^2.11.6 - version: 2.11.6(solid-js@1.9.7)(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) + version: 2.11.6(solid-js@2.0.0-beta.10)(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) vitest: specifier: ^2.1.9 version: 2.1.9(@types/node@22.15.31)(jsdom@25.0.1)(sass@1.77.8)(terser@5.42.0) @@ -298,9 +298,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/event-props: devDependencies: @@ -571,9 +574,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/memo: dependencies: @@ -786,9 +792,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/scheduled: devDependencies: @@ -884,9 +893,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/storage: dependencies: @@ -959,23 +971,22 @@ importers: version: 1.9.7 packages/upload: - dependencies: - '@solid-primitives/utils': - specifier: workspace:^ - version: link:../utils devDependencies: '@solidjs/web': - specifier: 2.0.0-beta.7 - version: 2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7) + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: 2.0.0-beta.7 - version: 2.0.0-beta.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/utils: devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/virtual: dependencies: @@ -1051,10 +1062,10 @@ importers: version: link:../packages/utils '@solidjs/meta': specifier: ^0.29.3 - version: 0.29.4(solid-js@2.0.0-beta.7) + version: 0.29.4(solid-js@2.0.0-beta.10) '@solidjs/router': specifier: ^0.13.1 - version: 0.13.6(solid-js@2.0.0-beta.7) + version: 0.13.6(solid-js@2.0.0-beta.10) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -1081,13 +1092,13 @@ importers: version: 1.77.8 solid-dismiss: specifier: ^1.7.121 - version: 1.8.2(solid-js@2.0.0-beta.7) + version: 1.8.2(solid-js@2.0.0-beta.10) solid-icons: specifier: ^1.1.0 - version: 1.1.0(solid-js@2.0.0-beta.7) + version: 1.1.0(solid-js@2.0.0-beta.10) solid-tippy: specifier: ^0.2.1 - version: 0.2.1(solid-js@2.0.0-beta.7)(tippy.js@6.3.7) + version: 0.2.1(solid-js@2.0.0-beta.10)(tippy.js@6.3.7) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -2591,19 +2602,18 @@ packages: peerDependencies: solid-js: ^1.5.3 - '@solidjs/signals@2.0.0-beta.7': - resolution: {integrity: sha512-SgK6oQlQZofz82LiEJ2RzT3sbs1lWTqFEtLoWjLsUo/dk1v9EoIFpJJlmvgkXvNugASWG+l1yOHa1a8lPamxug==} + '@solidjs/signals@2.0.0-beta.10': + resolution: {integrity: sha512-McdmbLNiSlz616zcykS8Rb1t9QTOTKdNAoaWd4/OjXEbcAUrPqRX1CWgR+caiWUk4qn0a+LesTTV4jZhFFPaSg==} '@solidjs/start@1.1.4': resolution: {integrity: sha512-ma1TBYqoTju87tkqrHExMReM5Z/+DTXSmi30CCTavtwuR73Bsn4rVGqm528p4sL2koRMfAuBMkrhuttjzhL68g==} peerDependencies: vinxi: ^0.5.3 - '@solidjs/web@2.0.0-beta.7': - resolution: {integrity: sha512-m5VjmDBufrOX0ZKGbhvwkT0CPK0TbMxDbxVPDB1PH2evGbWXQZcUlrpFM1N8RBO5md3aR/T1PgMfnOjleJbrRg==} + '@solidjs/web@2.0.0-beta.10': + resolution: {integrity: sha512-Ox7MBv19kuxHoHhWoLCCcc6aykSgaqzWvWT7RB66VqlFnQ8Lid2ncd30g5L4XC0GB+MN/WZVb68tiYrAFUDIAg==} peerDependencies: - '@solidjs/signals': ^2.0.0-beta.7 - solid-js: ^2.0.0-beta.7 + solid-js: ^2.0.0-beta.10 '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} @@ -6026,8 +6036,8 @@ packages: solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} - solid-js@2.0.0-beta.7: - resolution: {integrity: sha512-7JHs+BhLeZXoU+u9dG+eKnyxxfZyGpOuJEBbN/1XbHKO/WhxecdplOAurlg/YDllNWPhsbXqmLR1H2paqSu62g==} + solid-js@2.0.0-beta.10: + resolution: {integrity: sha512-EAfV6b1SC4c3wEBAoX4dMy063uTb4nfL5uXnN8yse4InH7RTw1LoB0I9HAy+pj3/GHqQE2tYZurlZtqU4pGyog==} solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} @@ -8606,21 +8616,21 @@ snapshots: dependencies: solid-js: 1.9.7 - '@solidjs/meta@0.29.4(solid-js@2.0.0-beta.7)': + '@solidjs/meta@0.29.4(solid-js@2.0.0-beta.10)': dependencies: - solid-js: 2.0.0-beta.7 + solid-js: 2.0.0-beta.10 - '@solidjs/router@0.13.6(solid-js@2.0.0-beta.7)': + '@solidjs/router@0.13.6(solid-js@2.0.0-beta.10)': dependencies: - solid-js: 2.0.0-beta.7 + solid-js: 2.0.0-beta.10 '@solidjs/router@0.8.4(solid-js@1.9.7)': dependencies: solid-js: 1.9.7 - '@solidjs/signals@2.0.0-beta.7': {} + '@solidjs/signals@2.0.0-beta.10': {} - '@solidjs/start@1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': + '@solidjs/start@1.1.4(solid-js@2.0.0-beta.10)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': dependencies: '@tanstack/server-functions-plugin': 1.121.0(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) @@ -8633,22 +8643,21 @@ snapshots: seroval-plugins: 1.3.2(seroval@1.3.2) shiki: 1.29.2 source-map-js: 1.2.1 - terracotta: 1.0.6(solid-js@1.9.7) + terracotta: 1.0.6(solid-js@2.0.0-beta.10) tinyglobby: 0.2.14 vinxi: 0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0) - vite-plugin-solid: 2.11.6(solid-js@1.9.7)(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) + vite-plugin-solid: 2.11.6(solid-js@2.0.0-beta.10)(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) transitivePeerDependencies: - '@testing-library/jest-dom' - solid-js - supports-color - vite - '@solidjs/web@2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7)': + '@solidjs/web@2.0.0-beta.10(solid-js@2.0.0-beta.10)': dependencies: - '@solidjs/signals': 2.0.0-beta.7 - seroval: 1.3.2 - seroval-plugins: 1.3.2(seroval@1.3.2) - solid-js: 2.0.0-beta.7 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-js: 2.0.0-beta.10 '@speed-highlight/core@1.2.7': {} @@ -10014,13 +10023,13 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - esbuild-plugin-solid@0.6.0(esbuild@0.25.5)(solid-js@1.9.7): + esbuild-plugin-solid@0.6.0(esbuild@0.25.5)(solid-js@2.0.0-beta.10): dependencies: '@babel/core': 7.27.4 '@babel/preset-typescript': 7.27.1(@babel/core@7.27.4) babel-preset-solid: 1.9.6(@babel/core@7.27.4) esbuild: 0.25.5 - solid-js: 1.9.7 + solid-js: 2.0.0-beta.10 transitivePeerDependencies: - supports-color @@ -12602,13 +12611,13 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - solid-dismiss@1.8.2(solid-js@2.0.0-beta.7): + solid-dismiss@1.8.2(solid-js@2.0.0-beta.10): dependencies: - solid-js: 2.0.0-beta.7 + solid-js: 2.0.0-beta.10 - solid-icons@1.1.0(solid-js@2.0.0-beta.7): + solid-icons@1.1.0(solid-js@2.0.0-beta.10): dependencies: - solid-js: 2.0.0-beta.7 + solid-js: 2.0.0-beta.10 solid-js@1.9.7: dependencies: @@ -12616,25 +12625,25 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) - solid-js@2.0.0-beta.7: + solid-js@2.0.0-beta.10: dependencies: - '@solidjs/signals': 2.0.0-beta.7 + '@solidjs/signals': 2.0.0-beta.10 csstype: 3.1.3 seroval: 1.5.2 seroval-plugins: 1.5.2(seroval@1.5.2) - solid-refresh@0.6.3(solid-js@1.9.7): + solid-refresh@0.6.3(solid-js@2.0.0-beta.10): dependencies: '@babel/generator': 7.27.5 '@babel/helper-module-imports': 7.27.1 '@babel/types': 7.27.6 - solid-js: 1.9.7 + solid-js: 2.0.0-beta.10 transitivePeerDependencies: - supports-color - solid-tippy@0.2.1(solid-js@2.0.0-beta.7)(tippy.js@6.3.7): + solid-tippy@0.2.1(solid-js@2.0.0-beta.10)(tippy.js@6.3.7): dependencies: - solid-js: 2.0.0-beta.7 + solid-js: 2.0.0-beta.10 tippy.js: 6.3.7 solid-transition-group@0.2.3(solid-js@1.9.7): @@ -12643,9 +12652,9 @@ snapshots: '@solid-primitives/transition-group': 1.0.5(solid-js@1.9.7) solid-js: 1.9.7 - solid-use@0.9.1(solid-js@1.9.7): + solid-use@0.9.1(solid-js@2.0.0-beta.10): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.10 source-map-js@1.2.1: {} @@ -12866,10 +12875,10 @@ snapshots: term-size@2.2.1: {} - terracotta@1.0.6(solid-js@1.9.7): + terracotta@1.0.6(solid-js@2.0.0-beta.10): dependencies: - solid-js: 1.9.7 - solid-use: 0.9.1(solid-js@1.9.7) + solid-js: 2.0.0-beta.10 + solid-use: 0.9.1(solid-js@2.0.0-beta.10) terser@5.42.0: dependencies: @@ -13296,14 +13305,14 @@ snapshots: - supports-color - terser - vite-plugin-solid@2.11.6(solid-js@1.9.7)(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)): + vite-plugin-solid@2.11.6(solid-js@2.0.0-beta.10)(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)): dependencies: '@babel/core': 7.27.4 '@types/babel__core': 7.20.5 babel-preset-solid: 1.9.6(@babel/core@7.27.4) merge-anything: 5.1.7 - solid-js: 1.9.7 - solid-refresh: 0.6.3(solid-js@1.9.7) + solid-js: 2.0.0-beta.10 + solid-refresh: 0.6.3(solid-js@2.0.0-beta.10) vite: 6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0) vitefu: 1.0.6(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) transitivePeerDependencies: From 8ff220f998ec50efda7050b03f86be91f97d64aa Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 17 May 2026 11:41:24 -0400 Subject: [PATCH 05/13] Renamed createFileUpload and added createFileUploader --- packages/upload/README.md | 314 ++++++++--- packages/upload/package.json | 3 + packages/upload/src/createDropzone.ts | 59 ++- packages/upload/src/createFilePicker.ts | 100 ++++ packages/upload/src/createFileUploader.ts | 175 ++++--- packages/upload/src/fileSender.ts | 64 +++ packages/upload/src/index.ts | 3 + packages/upload/src/types.ts | 51 +- packages/upload/test/index.test.tsx | 600 +++++++++++++++++++++- 9 files changed, 1202 insertions(+), 167 deletions(-) create mode 100644 packages/upload/src/createFilePicker.ts create mode 100644 packages/upload/src/fileSender.ts diff --git a/packages/upload/README.md b/packages/upload/README.md index 1609fd0ad..eaede9b58 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -8,7 +8,13 @@ [![size](https://img.shields.io/npm/v/@solid-primitives/upload?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/upload) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Primitive to make uploading files and making dropzones easier. +Primitives for file picking, drag-and-drop zones, and XHR uploads with progress tracking. + +- [`createFilePicker`](#createfilepicker) — opens the OS file-picker and exposes selected files reactively +- [`createFileUploader`](#createfileuploader) — uploads files with reactive progress, status, and error; transport is passed in explicitly +- [`fileSender`](#filesender) — XHR transport factory for `createFileUploader` (tree-shakeable) +- [`fileUploader`](#fileuploader) — ref callback factory for `` elements +- [`createDropzone`](#createdropzone) — reactive drag-and-drop zone with full drag-event callbacks ## Installation @@ -20,47 +26,180 @@ yarn add @solid-primitives/upload pnpm add @solid-primitives/upload ``` -> **Requires Solid.js 2.0 and `@solidjs/web` 2.0.** +## `createFilePicker` + +Opens the OS file-picker dialog when called and exposes the selected files, loading state, and any callback error as reactive signals. + +```ts +import { createFilePicker } from "@solid-primitives/upload"; + +// Single file +const { files, isLoading, error, selectFiles } = createFilePicker(); + +// Multiple files with MIME filter +const { files, isLoading, error, selectFiles } = createFilePicker({ + multiple: true, + accept: "image/*", +}); + +// Open the picker and do something with the selection +selectFiles(async files => { + await uploadToServer(files); +}); +``` + +**Returned object:** + +| Name | Type | Description | +| ------------- | ----------------------------------- | ---------------------------------------------------------------- | +| `files` | `Accessor` | Reactive list of selected files; updated on every selection | +| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | +| `isLoading` | `Accessor` | `true` while the `selectFiles` callback is pending | +| `selectFiles` | `(callback?: UserCallback) => void` | Opens the file-picker and runs the optional callback on change | +| `removeFile` | `(fileName: string) => void` | Removes a single file from the list by name | +| `clearFiles` | `() => void` | Clears all selected files | + +**Options:** + +| Option | Type | Default | Description | +| ---------- | --------- | ------- | --------------------------------------------------------------------------------------------------------- | +| `accept` | `string` | `""` | Comma-separated list of accepted file types (passed to ``). E.g. `"image/*"`, `".pdf,.doc"` | +| `multiple` | `boolean` | `false` | Allow selecting more than one file at once | + +**Usage example — combined picker + uploader:** -## How to use it +```tsx +import { createFilePicker, createFileUploader, fileSender } from "@solid-primitives/upload"; + +const { selectFiles } = createFilePicker({ multiple: true, accept: "image/*" }); +const { upload, files, progress, status } = createFileUploader(fileSender("/api/upload")); + + + + + + {progress().percentage}% + + +{f => +
+ {f.file.name} + + — failed: {String(f.error)} + +
+}
+``` -### [createFileUploader](#createfileuploader) +## `createFileUploader` -A reactive primitive that opens the OS file-picker dialog and exposes the selected files as a signal. +Uploads files with reactive per-file and aggregate progress, status, and error tracking. Each file in a batch gets its own parallel request. The transport is passed in explicitly — use the bundled `fileSender` factory for XHR, or supply your own. Keeping them separate lets bundlers tree-shake `fileSender` when it is not needed. ```ts -// single file -const { files, selectFiles } = createFileUploader(); -selectFiles(([file]) => console.log(file)); +import { createFileUploader, fileSender } from "@solid-primitives/upload"; -// multiple files with accept filter -const { files, selectFiles } = createFileUploader({ multiple: true, accept: "image/*" }); -selectFiles(files => files.forEach(file => console.log(file))); +const { upload, files, progress, status, abort } = createFileUploader(fileSender("/api/upload")); + +// upload() dispatches one request per file (in parallel); resolves when all settle +const results = await upload(myFiles); ``` -**Returns:** +**Returned object:** + +| Name | Type | Description | +| ---------- | -------------------------------------------- | ----------------------------------------------------------------- | +| `upload` | `(files: UploadFile[]) => Promise` | Send files in parallel; resolves when all settle | +| `files` | `readonly FileUploadEntry[]` | Store array — per-file progress, status, error, and response | +| `progress` | `Accessor` | Aggregate `{ loaded, total, percentage }` across all files | +| `status` | `Accessor` | Aggregate status: `uploading > error > aborted > success > idle` | +| `abort` | `() => void` | Cancel all in-flight uploads | +| `removeFile` | `(fileName: string) => void` | Remove one entry by name; aborts all in-flight uploads | +| `clearFiles` | `() => void` | Remove all entries; aborts all in-flight uploads | -| Name | Type | Description | -| ------------- | ----------------------------------- | --------------------------------------------------------------- | -| `files` | `Accessor` | Reactive list of selected files | -| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | -| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all selected files | +Each entry in `files` has the shape: -### [fileUploader](#fileuploader-ref-callback) +```ts +type FileUploadEntry = { + file: UploadFile; + progress: UploadProgress; // { loaded, total, percentage } + status: UploadStatus; // "idle" | "uploading" | "success" | "error" | "aborted" + error: unknown; // error from a failed upload; null otherwise + response: unknown; // parsed server response on success; null otherwise +}; +``` -A **ref callback factory** for `` elements (replaces the Solid 1.x `use:fileUploader` directive). +Read `files[i]` directly in JSX for fine-grained per-file reactivity — only the row that changed re-renders: ```tsx +{f => +
+ {f.file.name} — {f.progress.percentage}% + + Error: {String(f.error)} + +
+}
+``` + +**Calling `upload` again while one is in-flight cancels the previous upload** and resets all per-file state. Use `abort()` to cancel without starting a new one. + +## `fileSender` + +Factory that creates a `SendFunction` backed by XHR. Imported separately so it can be tree-shaken when unused. + +```ts +import { fileSender } from "@solid-primitives/upload"; + +// Basic +createFileUploader(fileSender("/api/upload")); + +// With options +createFileUploader(fileSender("/api/upload", { fieldName: "attachment", headers: { "X-Auth": token } })); +``` + +**Options:** + +| Option | Type | Default | Description | +| ----------- | ------------------------ | -------- | ------------------------------------------------------------------------------------- | +| `fieldName` | `string` | `"file"` | FormData field name used for each file | +| `headers` | `Record` | `{}` | Additional request headers (do not set `Content-Type`; the browser sets it for FormData) | + +**Custom `SendFunction`:** + +Provide your own transport — fetch, WebSocket, a test double, etc. The function is called once per file and receives the file, a progress callback, and an `AbortSignal`. It must return a `Promise` resolving with the server response or rejecting on failure. Reject with `new DOMException("...", "AbortError")` when the signal fires so that file's `status` transitions to `"aborted"`. + +```ts +const { upload, progress, status } = createFileUploader(async (file, onProgress, signal) => { + const body = new FormData(); + body.append("file", file.file, file.name); + const res = await fetch("/api/upload", { method: "POST", body, signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +}); +``` + +## `fileUploader` + +A ref callback factory for wiring an existing `` element into your own reactive state. Use this when you want full control over the input element's markup (e.g. for custom styling). + +```tsx +import { fileUploader } from "@solid-primitives/upload"; +import { createSignal } from "solid-js"; +import type { UploadFile } from "@solid-primitives/upload"; + const [files, setFiles] = createSignal([]); const [uploadError, setUploadError] = createSignal(null); fs.forEach(f => console.log(f)), + userCallback: async fs => { + await uploadToServer(fs); + }, setFiles, onError: err => setUploadError(err), })} @@ -69,75 +208,114 @@ const [uploadError, setUploadError] = createSignal(null); If `onError` is omitted, a rejection from `userCallback` propagates as an unhandled promise rejection. -> **Migration note (Solid 2.0):** The `use:fileUploader` directive syntax has been removed. -> Replace `use:fileUploader={opts}` with `ref={fileUploader(opts)}`. +**Options:** + +| Option | Type | Description | +| -------------- | -------------------------- | --------------------------------------------------------- | +| `userCallback` | `UserCallback` | Called with the parsed files on every `change` event | +| `setFiles` | `Setter` | Receives the parsed `UploadFile[]` on every change | +| `onError` | `(error: unknown) => void` | Called when `userCallback` throws; defaults to rethrowing | -### [createDropzone](#createdropzone) +## `createDropzone` -A reactive primitive for drag-and-drop file targets. +A reactive drag-and-drop zone. Attach it to any element via the `setRef` ref callback and respond to the full set of drag lifecycle events. ```tsx -const { - setRef: dropzoneRef, - files: droppedFiles, - isDragging, -} = createDropzone({ - onDrop: async files => { - await doStuff(2); - files.forEach(f => console.log(f)); - }, - onDragStart: files => files.forEach(f => console.log(f)), - onDragOver: files => console.log("drag over"), +import { createDropzone, createFileUploader, fileSender } from "@solid-primitives/upload"; + +const { upload, progress, status } = createFileUploader(fileSender("/api/upload")); +const { setRef, files, isDragging, error } = createDropzone({ + onDrop: files => upload(files), });
- Dropzone -
; + + Uploading… {progress().percentage}% + + +

Error: {String(error())}

+
+ {file =>

{file.name}

}
+
``` -**Returns:** - -| Name | Type | Description | -| ------------ | ---------------------------- | ------------------------------------------------------ | -| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | -| `files` | `Accessor` | Reactive list of dropped files | -| `error` | `Accessor` | Error thrown by the last drag callback; `null` if none | -| `isDragging` | `Accessor` | `true` while a drag is in progress | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all dropped files | - -**DropzoneOptions:** - -| Callback | Type | Description | -| ------------- | -------------- | -------------------------------------- | -| `onDrop` | `UserCallback` | Fired when files are dropped | -| `onDragStart` | `UserCallback` | Fired when a drag starts | -| `onDragEnter` | `UserCallback` | Fired when dragged item enters element | -| `onDragEnd` | `UserCallback` | Fired when drag ends | -| `onDragLeave` | `UserCallback` | Fired when dragged item leaves element | -| `onDragOver` | `UserCallback` | Fired continuously while dragging over | -| `onDrag` | `UserCallback` | Fired on drag events | +**Returned object:** + +| Name | Type | Description | +| ------------ | ---------------------------- | -------------------------------------------------------------------- | +| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of the drop target element | +| `files` | `Accessor` | Reactive list of the most recently dropped files | +| `error` | `Accessor` | Error thrown by the last `onDrop` callback; `null` if none | +| `isLoading` | `Accessor` | `true` while the `onDrop` callback is pending | +| `isDragging` | `Accessor` | `true` while a drag is active over the element | +| `removeFile` | `(fileName: string) => void` | Removes a single file from the list by name | +| `clearFiles` | `() => void` | Clears all dropped files | + +**Options (all optional):** + +| Callback | Fires when… | +| ------------- | -------------------------------------------------------- | +| `onDrop` | Files are dropped; `isLoading` is `true` while it awaits | +| `onDragStart` | A drag operation begins | +| `onDragEnter` | A dragged item enters the element | +| `onDragEnd` | A drag operation ends | +| `onDragLeave` | A dragged item leaves the element | +| `onDragOver` | An item is dragged continuously over the element | +| `onDrag` | Any drag event fires on the element | + +All callbacks have signature `(files: UploadFile[]) => void | Promise`. `isLoading` tracks only the `onDrop` callback — drag-movement events are fire-and-forget. ## Types ```ts type UploadFile = { - source: string; // blob URL (URL.createObjectURL) + source: string; // blob URL from URL.createObjectURL name: string; size: number; file: File; }; +type UploadStatus = "idle" | "uploading" | "success" | "error" | "aborted"; + +type UploadProgress = { + loaded: number; + total: number; + percentage: number; // 0–100 +}; + +type SendFunction = ( + file: UploadFile, + onProgress: (progress: UploadProgress) => void, + signal: AbortSignal, +) => Promise; + +type FileUploadEntry = { + file: UploadFile; + progress: UploadProgress; + status: UploadStatus; + error: unknown; + response: unknown; +}; + type UserCallback = (files: UploadFile[]) => void | Promise; -type FileUploaderOptions = { +type FilePickerOptions = { accept?: string; multiple?: boolean; }; +type FileSenderOptions = { + fieldName?: string; + headers?: Record; +}; + type FileUploaderDirective = { userCallback: UserCallback; setFiles: Setter; @@ -145,9 +323,13 @@ type FileUploaderDirective = { }; ``` +## SSR + +All primitives are SSR-safe. On the server they return no-op stubs so components render without errors. + ## Demo -Working example: https://primitives.solidjs.community/playground/upload +Working example: https://primitives.solidjs.community/playground/upload Source code: https://github.com/solidjs-community/solid-primitives/blob/main/packages/upload/dev/index.tsx ## Changelog diff --git a/packages/upload/package.json b/packages/upload/package.json index de0d89fba..63e0da34c 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -13,7 +13,10 @@ "name": "upload", "stage": 0, "list": [ + "createFilePicker", "createFileUploader", + "fileSender", + "fileUploader", "createDropzone" ], "category": "Browser APIs" diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 6b568519a..78cd35e51 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -1,4 +1,4 @@ -import { createSignal, type JSX, getOwner, onCleanup, runWithOwner } from "solid-js"; +import { createSignal, getOwner, onCleanup, runWithOwner } from "solid-js"; import { isServer } from "@solidjs/web"; import { transformFiles } from "./helpers.js"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; @@ -9,6 +9,7 @@ import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; * @returns `setRef` * @returns `files` * @returns `error` - Reactive error from the last drag callback, cleared on next drop + * @returns `isLoading` - True while the `onDrop` callback is pending * @returns `isDragging` * @returns `removeFile` * @returns `clearFiles` @@ -33,6 +34,7 @@ function createDropzone( setRef: () => {}, files: () => [], error: () => null, + isLoading: () => false, isDragging: () => false, removeFile: () => {}, clearFiles: () => {}, @@ -40,6 +42,7 @@ function createDropzone( } const [files, setFiles] = createSignal([]); const [error, setError] = createSignal(null); + const [isLoading, setIsLoading] = createSignal(false); const [isDragging, setIsDragging] = createSignal(false); // Capture owner in the Phase-1 (owned) scope so cleanup can be registered later @@ -56,55 +59,64 @@ function createDropzone( } }; - const onDragStart: JSX.EventHandler = event => { + const onDragStart = (event: DragEvent) => { setIsDragging(true); void runCallback(options?.onDragStart, transformFiles(event.dataTransfer?.files || null)); }; - const onDragEnd: JSX.EventHandler = event => { + const onDragEnd = (event: DragEvent) => { setIsDragging(false); void runCallback(options?.onDragEnd, transformFiles(event.dataTransfer?.files || null)); }; - const onDragEnter: JSX.EventHandler = event => { + const onDragEnter = (event: DragEvent) => { void runCallback(options?.onDragEnter, transformFiles(event.dataTransfer?.files || null)); }; - const onDragLeave: JSX.EventHandler = event => { + const onDragLeave = (event: DragEvent) => { void runCallback(options?.onDragLeave, transformFiles(event.dataTransfer?.files || null)); }; - const onDragOver: JSX.EventHandler = event => { + const onDragOver = (event: DragEvent) => { event.preventDefault(); void runCallback(options?.onDragOver, transformFiles(event.dataTransfer?.files || null)); }; - const onDrag: JSX.EventHandler = event => { + const onDrag = (event: DragEvent) => { void runCallback(options?.onDrag, transformFiles(event.dataTransfer?.files || null)); }; - const onDrop: JSX.EventHandler = event => { + const onDrop = (event: DragEvent) => { event.preventDefault(); const parsedFiles = transformFiles(event.dataTransfer?.files || null); setFiles(parsedFiles); setError(null); - void runCallback(options?.onDrop, parsedFiles); + setIsLoading(true); + void (async () => { + try { + await options?.onDrop?.(parsedFiles); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + })(); }; // setRef is the Phase-2 ref callback: called synchronously when the element is created. // Listeners are attached immediately; cleanup is registered back in the owner scope. const setRef = (ref: T) => { - ref.addEventListener("dragstart", onDragStart as any); - ref.addEventListener("dragenter", onDragEnter as any); - ref.addEventListener("dragend", onDragEnd as any); - ref.addEventListener("dragleave", onDragLeave as any); - ref.addEventListener("dragover", onDragOver as any); - ref.addEventListener("drag", onDrag as any); - ref.addEventListener("drop", onDrop as any); + ref.addEventListener("dragstart", onDragStart); + ref.addEventListener("dragenter", onDragEnter); + ref.addEventListener("dragend", onDragEnd); + ref.addEventListener("dragleave", onDragLeave); + ref.addEventListener("dragover", onDragOver); + ref.addEventListener("drag", onDrag); + ref.addEventListener("drop", onDrop); runWithOwner(owner, () => { onCleanup(() => { - ref.removeEventListener("dragstart", onDragStart as any); - ref.removeEventListener("dragenter", onDragEnter as any); - ref.removeEventListener("dragend", onDragEnd as any); - ref.removeEventListener("dragleave", onDragLeave as any); - ref.removeEventListener("dragover", onDragOver as any); - ref.removeEventListener("drag", onDrag as any); - ref.removeEventListener("drop", onDrop as any); + ref.removeEventListener("dragstart", onDragStart); + ref.removeEventListener("dragenter", onDragEnter); + ref.removeEventListener("dragend", onDragEnd); + ref.removeEventListener("dragleave", onDragLeave); + ref.removeEventListener("dragover", onDragOver); + ref.removeEventListener("drag", onDrag); + ref.removeEventListener("drop", onDrop); }); }); }; @@ -121,6 +133,7 @@ function createDropzone( setRef, files, error, + isLoading, isDragging, removeFile, clearFiles, diff --git a/packages/upload/src/createFilePicker.ts b/packages/upload/src/createFilePicker.ts new file mode 100644 index 000000000..88821a509 --- /dev/null +++ b/packages/upload/src/createFilePicker.ts @@ -0,0 +1,100 @@ +import { createSignal } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { transformFiles, createInputComponent } from "./helpers.js"; +import type { FilePicker, FilePickerOptions, UploadFile, UserCallback } from "./types.js"; + +/** + * Primitive to open the OS file-picker and expose the selected files reactively. + * + * @returns `files` + * @returns `error` - Reactive error from the last `selectFiles` callback, cleared on next selection + * @returns `isLoading` - True while the `selectFiles` callback is pending + * @returns `selectFiles` - Open file picker, set files and run user callback + * @returns `removeFile` + * @returns `clearFiles` + * + * @example + * ```ts + * // multiple files + * const { files, error, selectFiles } = createFilePicker({ multiple: true, accept: "image/*" }); + * selectFiles(files => files.forEach(file => console.log(file))); + * + * // single file + * const { files, error, selectFiles } = createFilePicker(); + * selectFiles(([{ source, name, size, file }]) => console.log({ source, name, size, file })); + * ``` + */ +function createFilePicker(options?: FilePickerOptions): FilePicker { + if (isServer) { + return { + files: () => [], + error: () => null, + isLoading: () => false, + selectFiles: () => {}, + removeFile: () => {}, + clearFiles: () => {}, + }; + } + const [files, setFiles] = createSignal([]); + const [error, setError] = createSignal(null); + const [isLoading, setIsLoading] = createSignal(false); + + let userCallback: UserCallback = () => {}; + + const onChange = async (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget as HTMLInputElement; + + let parsedFiles: UploadFile[] = []; + if (target.files) { + parsedFiles = transformFiles(target.files); + } + + target.removeEventListener("change", onChange); + target.remove(); + + setFiles(parsedFiles); + setError(null); + setIsLoading(true); + + try { + await userCallback(parsedFiles); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + }; + + const selectFiles = (callback?: UserCallback) => { + if (callback) { + userCallback = callback; + } + + const inputElement = createInputComponent(options || {}); + + inputElement.addEventListener("change", onChange); + inputElement.click(); + }; + + const removeFile = (fileName: string) => { + setFiles(prev => prev.filter(f => f.name !== fileName)); + }; + + const clearFiles = () => { + setFiles([]); + }; + + return { + files, + error, + isLoading, + selectFiles, + removeFile, + clearFiles, + }; +} + +export { createFilePicker }; diff --git a/packages/upload/src/createFileUploader.ts b/packages/upload/src/createFileUploader.ts index e2c58a4de..12331fa52 100644 --- a/packages/upload/src/createFileUploader.ts +++ b/packages/upload/src/createFileUploader.ts @@ -1,93 +1,148 @@ -import { createSignal, type JSX } from "solid-js"; +import { createStore, createMemo, onCleanup } from "solid-js"; import { isServer } from "@solidjs/web"; -import { transformFiles, createInputComponent } from "./helpers.js"; -import type { FileUploader, FileUploaderOptions, UploadFile, UserCallback } from "./types.js"; +import type { + FileUploader, + FileUploadEntry, + SendFunction, + UploadFile, + UploadProgress, + UploadStatus, +} from "./types.js"; /** - * Primitive to make uploading files easier. + * Primitive for uploading files with per-file and aggregate reactive state. * - * @returns `files` - * @returns `error` - Reactive error from the last `selectFiles` callback, cleared on next selection - * @returns `selectFiles` - Open file picker, set files and run user callback - * @returns `removeFile` - * @returns `clearFiles` + * Each file in a batch gets its own request (one `send` call per file, all in parallel). + * Per-file `progress`, `status`, `error`, and `response` are tracked in a store array + * for fine-grained reactivity — read `files[i].status` directly in JSX. + * Aggregate `progress` and `status` are derived memos across all entries. + * + * Pass a `SendFunction` to control the transport. Use the exported `fileSender` factory + * for XHR uploads, or supply your own to use fetch, WebSocket, etc. Keeping them separate + * allows bundlers to tree-shake `fileSender` when it is not used. + * + * @param send - Transport function called once per file (e.g. `fileSender("/api/upload")`) * * @example * ```ts - * // multiple files - * const { files, error, selectFiles } = createFileUploader({ multiple: true, accept: "image/*" }); - * selectFiles(files => files.forEach(file => console.log(file))); + * import { createFileUploader, fileSender } from "@solid-primitives/upload"; * - * // single file - * const { files, error, selectFiles } = createFileUploader(); - * selectFiles(([{ source, name, size, file }]) => console.log({ source, name, size, file })); + * const { upload, files, progress, status } = createFileUploader(fileSender("/api/upload")); + * + * // Per-file state in JSX: + * // {f =>

{f.file.name} — {f.progress.percentage}%

}
* ``` */ -function createFileUploader(options?: FileUploaderOptions): FileUploader { +function createFileUploader(send: SendFunction): FileUploader { if (isServer) { return { - files: () => [], - error: () => null, - selectFiles: () => {}, + upload: async () => [], + files: [] as readonly FileUploadEntry[], + progress: () => ({ loaded: 0, total: 0, percentage: 0 }), + status: () => "idle", + abort: () => {}, removeFile: () => {}, clearFiles: () => {}, }; } - const [files, setFiles] = createSignal([]); - const [error, setError] = createSignal(null); - - let userCallback: UserCallback = () => {}; - const onChange: JSX.EventHandler = async event => { - event.preventDefault(); - event.stopPropagation(); + const [files, setFiles] = createStore([]); + let controllers: AbortController[] = []; + // Incremented on each upload() call or removal; lets stale async closures silently abandon their writes. + let generation = 0; - const target = event.currentTarget; + const upload = async (uploadFiles: UploadFile[]): Promise => { + controllers.forEach(c => c.abort()); + controllers = uploadFiles.map(() => new AbortController()); + const thisGen = ++generation; - let parsedFiles: UploadFile[] = []; - if (target.files) { - parsedFiles = transformFiles(target.files); - } + setFiles(() => + uploadFiles.map(file => ({ + file, + progress: { loaded: 0, total: 0, percentage: 0 }, + status: "uploading" as UploadStatus, + error: null, + response: null, + })), + ); - target.removeEventListener("change", onChange as any); - target.remove(); + const settled = await Promise.allSettled( + uploadFiles.map((uploadFile, i) => + send( + uploadFile, + (p: UploadProgress) => { + if (generation === thisGen) setFiles(s => { s[i]!.progress = p; }); + }, + controllers[i]!.signal, + ).then( + result => { + if (generation === thisGen) { + setFiles(s => { + s[i]!.status = "success"; + s[i]!.response = result; + }); + } + return result; + }, + (err: unknown) => { + if (generation === thisGen) { + setFiles(s => { + s[i]!.status = + err instanceof DOMException && err.name === "AbortError" ? "aborted" : "error"; + s[i]!.error = + err instanceof DOMException && err.name === "AbortError" ? null : err; + }); + } + return undefined; + }, + ), + ), + ); - setFiles(parsedFiles); - setError(null); - - try { - await userCallback(parsedFiles); - } catch (err) { - setError(err); - } + return settled.map(r => (r.status === "fulfilled" ? r.value : undefined)); }; - const selectFiles = (callback?: UserCallback) => { - if (callback) { - userCallback = callback; - } - - const inputElement = createInputComponent(options || {}); - - inputElement.addEventListener("change", onChange as any); - inputElement.click(); - }; + const abort = () => controllers.forEach(c => c.abort()); const removeFile = (fileName: string) => { - setFiles(prev => prev.filter(f => f.name !== fileName)); + controllers.forEach(c => c.abort()); + controllers = []; + generation++; + setFiles(s => { + const idx = s.findIndex(f => f.file.name === fileName); + if (idx !== -1) s.splice(idx, 1); + }); }; const clearFiles = () => { - setFiles([]); + controllers.forEach(c => c.abort()); + controllers = []; + generation++; + setFiles(() => []); }; - return { - files, - error, - selectFiles, - removeFile, - clearFiles, - }; + const progress = createMemo((): UploadProgress => { + if (files.length === 0) return { loaded: 0, total: 0, percentage: 0 }; + const loaded = files.reduce((sum, f) => sum + f.progress.loaded, 0); + const total = files.reduce((sum, f) => sum + f.progress.total, 0); + return { + loaded, + total, + percentage: total === 0 ? 0 : Math.round((loaded / total) * 100), + }; + }); + + const status = createMemo((): UploadStatus => { + if (files.length === 0) return "idle"; + if (files.some(f => f.status === "uploading")) return "uploading"; + if (files.some(f => f.status === "error")) return "error"; + if (files.some(f => f.status === "aborted")) return "aborted"; + return "success"; + }); + + onCleanup(abort); + + return { upload, files, progress, status, abort, removeFile, clearFiles }; } export { createFileUploader }; diff --git a/packages/upload/src/fileSender.ts b/packages/upload/src/fileSender.ts new file mode 100644 index 000000000..6a0e2d4ca --- /dev/null +++ b/packages/upload/src/fileSender.ts @@ -0,0 +1,64 @@ +import type { SendFunction } from "./types.js"; + +export type FileSenderOptions = { + fieldName?: string; + headers?: Record; +}; + +/** + * Creates a `SendFunction` that uploads files via XHR POST with progress events. + * + * Pass the result directly to `createFileUpload`: + * ```ts + * const { upload, progress } = createFileUpload(fileSender("/api/upload")); + * const { upload, progress } = createFileUpload(fileSender("/api/upload", { fieldName: "attachment" })); + * ``` + * + * @param url - Upload endpoint + * @param options.fieldName - FormData field name for each file (default: `"file"`) + * @param options.headers - Additional request headers (do not set `Content-Type`; the browser sets it) + */ +export function fileSender(url: string, options?: FileSenderOptions): SendFunction { + const { fieldName = "file", headers = {} } = options ?? {}; + + return (file, onProgress, signal) => + new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append(fieldName, file.file, file.name); + + const xhr = new XMLHttpRequest(); + + signal.addEventListener("abort", () => xhr.abort()); + + xhr.upload.addEventListener("progress", e => { + if (e.lengthComputable) { + onProgress({ + loaded: e.loaded, + total: e.total, + percentage: Math.round((e.loaded / e.total) * 100), + }); + } + }); + + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + let parsed: unknown = xhr.responseText; + try { + parsed = JSON.parse(xhr.responseText); + } catch {} + resolve(parsed); + } else { + reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`)); + } + }); + + xhr.addEventListener("error", () => reject(new Error("Network error during upload"))); + xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError"))); + + xhr.open("POST", url); + for (const [k, v] of Object.entries(headers)) { + xhr.setRequestHeader(k, v); + } + xhr.send(formData); + }); +} diff --git a/packages/upload/src/index.ts b/packages/upload/src/index.ts index 0c9d1da44..daf0c9766 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -1,4 +1,7 @@ +export { createFilePicker } from "./createFilePicker.js"; export { createFileUploader } from "./createFileUploader.js"; +export { fileSender } from "./fileSender.js"; +export type { FileSenderOptions } from "./fileSender.js"; export { createDropzone } from "./createDropzone.js"; export { fileUploader } from "./fileUploader.js"; export * from "./types.js"; diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index aa2475c6d..430e89bbb 100644 --- a/packages/upload/src/types.ts +++ b/packages/upload/src/types.ts @@ -14,16 +14,17 @@ export type UploadFile = { * @property `accept` - Comma-separated list of one or more file types, or unique file type specifiers * @link `accept` - https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept */ -export type FileUploaderOptions = { +export type FilePickerOptions = { accept?: string; multiple?: boolean; }; export type UserCallback = (files: UploadFile[]) => void | Promise; -export interface FileUploader { +export interface FilePicker { files: Accessor; error: Accessor; + isLoading: Accessor; selectFiles: (callback: (files: UploadFile[]) => void | Promise) => void; removeFile: (fileName: string) => void; clearFiles: () => void; @@ -39,6 +40,7 @@ export interface Dropzone { setRef: (ref: T) => void; files: Accessor; error: Accessor; + isLoading: Accessor; isDragging: Accessor; removeFile: (fileName: string) => void; clearFiles: () => void; @@ -57,3 +59,48 @@ export interface DropzoneOptions { onDragOver?: UserCallback; onDrag?: UserCallback; } + +export type UploadStatus = "idle" | "uploading" | "success" | "error" | "aborted"; + +export type UploadProgress = { + loaded: number; + total: number; + percentage: number; +}; + +/** + * Transport function called once per file. Receives the file, a progress callback, and an AbortSignal. + * Must return a Promise that resolves with the server response or rejects on failure. + * Reject with a DOMException("...", "AbortError") when the signal fires so status transitions to "aborted". + */ +export type SendFunction = ( + file: UploadFile, + onProgress: (progress: UploadProgress) => void, + signal: AbortSignal, +) => Promise; + +/** Reactive state for a single file within a `createFileUpload` batch. */ +export type FileUploadEntry = { + file: UploadFile; + progress: UploadProgress; + status: UploadStatus; + error: unknown; + response: unknown; +}; + +export interface FileUploader { + /** Send files. Each file gets its own request. Resolves when all files settle. */ + upload: (files: UploadFile[]) => Promise; + /** Store array — access entries directly in JSX for fine-grained reactivity. */ + files: readonly FileUploadEntry[]; + /** Aggregate progress across all files. */ + progress: Accessor; + /** Aggregate status: uploading > error > aborted > success > idle. */ + status: Accessor; + /** Cancel all in-flight uploads. */ + abort: () => void; + /** Remove a single file entry by name; aborts all in-flight uploads. */ + removeFile: (fileName: string) => void; + /** Remove all file entries and abort all in-flight uploads. */ + clearFiles: () => void; +} diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index cac65687d..e558fe834 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeAll } from "vitest"; -import { createRoot } from "solid-js"; -import { createFileUploader, createDropzone, fileUploader } from "../src/index.js"; +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { createRoot, flush } from "solid-js"; +import { createFilePicker, createFileUploader, fileSender, createDropzone, fileUploader } from "../src/index.js"; import { transformFiles } from "../src/helpers.js"; import type { UploadFile } from "../src/types.js"; @@ -55,22 +55,23 @@ describe("transformFiles", () => { }); }); -// ── createFileUploader ──────────────────────────────────────────────────────── +// ── createFilePicker ──────────────────────────────────────────────────────── -describe("createFileUploader", () => { - it("initialises with empty file list and no error", () => { - const { files, error, dispose } = createRoot(dispose => ({ - ...createFileUploader(), +describe("createFilePicker", () => { + it("initialises with empty file list, no error, and isLoading=false", () => { + const { files, error, isLoading, dispose } = createRoot(dispose => ({ + ...createFilePicker(), dispose, })); expect(files()).toEqual([]); expect(error()).toBeNull(); + expect(isLoading()).toBe(false); dispose(); }); it("initialises with empty file list (multiple mode)", () => { const { files, dispose } = createRoot(dispose => ({ - ...createFileUploader({ multiple: true }), + ...createFilePicker({ multiple: true }), dispose, })); expect(files()).toEqual([]); @@ -79,7 +80,7 @@ describe("createFileUploader", () => { it("removeFile is a no-op on an empty list", () => { const { files, removeFile, dispose } = createRoot(dispose => ({ - ...createFileUploader(), + ...createFilePicker(), dispose, })); removeFile("nonexistent.png"); @@ -89,7 +90,7 @@ describe("createFileUploader", () => { it("clearFiles empties the list", () => { const { files, clearFiles, dispose } = createRoot(dispose => ({ - ...createFileUploader(), + ...createFilePicker(), dispose, })); clearFiles(); @@ -99,7 +100,7 @@ describe("createFileUploader", () => { it("selectFiles triggers a file input click", () => { const { selectFiles, dispose } = createRoot(dispose => ({ - ...createFileUploader(), + ...createFilePicker(), dispose, })); @@ -120,7 +121,7 @@ describe("createFileUploader", () => { it("error() is null when callback succeeds", async () => { const { error, selectFiles, dispose } = createRoot(dispose => ({ - ...createFileUploader(), + ...createFilePicker(), dispose, })); @@ -148,10 +149,78 @@ describe("createFileUploader", () => { dispose(); }); + it("isLoading is true while callback is pending, false after it resolves", async () => { + let resolve!: () => void; + const blocker = new Promise(r => { + resolve = r; + }); + + const { isLoading, selectFiles, dispose } = createRoot(dispose => ({ + ...createFilePicker(), + dispose, + })); + + const origCreate = document.createElement.bind(document); + let createdInput: HTMLInputElement | undefined; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") { + createdInput = el as HTMLInputElement; + vi.spyOn(createdInput, "click").mockImplementation(() => { + dispatchChange(createdInput!, makeFileList(makeFile())); + }); + } + return el; + }); + + selectFiles(() => blocker); + await Promise.resolve(); // flush setIsLoading(true) from onChange sync section + + expect(isLoading()).toBe(true); + + resolve(); + await blocker; + await Promise.resolve(); // flush setIsLoading(false) from finally + + expect(isLoading()).toBe(false); + vi.restoreAllMocks(); + dispose(); + }); + + it("isLoading is false after callback throws", async () => { + const { isLoading, selectFiles, dispose } = createRoot(dispose => ({ + ...createFilePicker(), + dispose, + })); + + const origCreate = document.createElement.bind(document); + let createdInput: HTMLInputElement | undefined; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") { + createdInput = el as HTMLInputElement; + vi.spyOn(createdInput, "click").mockImplementation(() => { + dispatchChange(createdInput!, makeFileList(makeFile())); + }); + } + return el; + }); + + selectFiles(async () => { + throw new Error("oops"); + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(isLoading()).toBe(false); + vi.restoreAllMocks(); + dispose(); + }); + it("error() captures a thrown callback error", async () => { const boom = new Error("upload failed"); const { error, selectFiles, dispose } = createRoot(dispose => ({ - ...createFileUploader(), + ...createFilePicker(), dispose, })); @@ -183,13 +252,14 @@ describe("createFileUploader", () => { // ── createDropzone ──────────────────────────────────────────────────────────── describe("createDropzone", () => { - it("initialises with empty files, no error, and isDragging=false", () => { - const { files, error, isDragging, dispose } = createRoot(dispose => ({ + it("initialises with empty files, no error, isLoading=false, and isDragging=false", () => { + const { files, error, isLoading, isDragging, dispose } = createRoot(dispose => ({ ...createDropzone(), dispose, })); expect(files()).toEqual([]); expect(error()).toBeNull(); + expect(isLoading()).toBe(false); expect(isDragging()).toBe(false); dispose(); }); @@ -233,6 +303,63 @@ describe("createDropzone", () => { }).not.toThrow(); }); + it("isLoading is true while onDrop callback is pending, false after it resolves", async () => { + let resolve!: () => void; + const blocker = new Promise(r => { + resolve = r; + }); + + // Capture isLoading via closure — onDrop is passed before the primitive returns + let getIsLoading!: () => boolean; + const { setRef, dispose } = createRoot(dispose => { + const dz = createDropzone({ onDrop: () => blocker }); + getIsLoading = dz.isLoading; + return { ...dz, dispose }; + }); + + const div = document.createElement("div"); + setRef(div); + + const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, "dataTransfer", { + value: { files: makeFileList(makeFile()) }, + configurable: true, + }); + div.dispatchEvent(dropEvent); + await Promise.resolve(); // flush setIsLoading(true) + + expect(getIsLoading()).toBe(true); + + resolve(); + await blocker; + await Promise.resolve(); // flush setIsLoading(false) from finally + + expect(getIsLoading()).toBe(false); + dispose(); + }); + + it("isLoading is false after onDrop throws", async () => { + const { isLoading, setRef, dispose } = createRoot(dispose => ({ + ...createDropzone({ onDrop: async () => { throw new Error("oops"); } }), + dispose, + })); + + const div = document.createElement("div"); + setRef(div); + + const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, "dataTransfer", { + value: { files: makeFileList(makeFile()) }, + configurable: true, + }); + div.dispatchEvent(dropEvent); + await Promise.resolve(); + await Promise.resolve(); + + expect(isLoading()).toBe(false); + dispose(); + }); + it("error() captures a thrown onDrop callback", async () => { const boom = new Error("drop failed"); const { error, setRef, dispose } = createRoot(dispose => ({ @@ -339,3 +466,444 @@ describe("fileUploader", () => { expect(onError).toHaveBeenCalledWith(boom); }); }); + +// ── createFileUploader ────────────────────────────────────────────────────────── + +// Builds a minimal UploadFile without needing a real picker +function makeUploadFile(name = "test.png"): UploadFile { + const file = makeFile(name); + return { source: `blob:${name}`, name, size: file.size, file }; +} + +// Custom send helper: called once per file; tracks all concurrent calls for multi-file tests. +function makeBlockingSend() { + type ProgressCb = (p: { loaded: number; total: number; percentage: number }) => void; + type Call = { file: UploadFile; resolve: (v: unknown) => void; reject: (e: unknown) => void; onProgress: ProgressCb }; + const calls: Call[] = []; + + const send = vi.fn((file: UploadFile, onProgress: ProgressCb, signal: AbortSignal) => + new Promise((res, rej) => { + calls.push({ file, resolve: res, reject: rej, onProgress }); + signal.addEventListener("abort", () => rej(new DOMException("Upload aborted", "AbortError"))); + }), + ); + + const emitProgressCall = (i: number, loaded: number, total: number) => + calls[i]!.onProgress({ loaded, total, percentage: Math.round((loaded / total) * 100) }); + + return { + send, + resolve: (val: unknown = "ok") => calls[0]!.resolve(val), + reject: (err: unknown) => calls[0]!.reject(err), + emitProgress: (loaded: number, total: number) => emitProgressCall(0, loaded, total), + resolveCall: (i: number, val: unknown = "ok") => calls[i]!.resolve(val), + rejectCall: (i: number, err: unknown) => calls[i]!.reject(err), + emitProgressCall, + callCount: () => calls.length, + }; +} + +describe("createFileUploader", () => { + it("starts with idle status, zero progress, and empty files array", () => { + const { status, progress, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(makeBlockingSend().send), + dispose, + })); + expect(status()).toBe("idle"); + expect(progress()).toEqual({ loaded: 0, total: 0, percentage: 0 }); + expect(files.length).toBe(0); + dispose(); + }); + + it("status: idle → uploading → success", async () => { + const { send, resolve } = makeBlockingSend(); + const { upload, status, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + expect(status()).toBe("idle"); + + const uploadPromise = upload([makeUploadFile()]).catch(() => {}); + await Promise.resolve(); + flush(); + expect(status()).toBe("uploading"); + + resolve("response-data"); + await uploadPromise; + flush(); + expect(status()).toBe("success"); + dispose(); + }); + + it("per-file response is set after a successful upload", async () => { + const { send, resolve } = makeBlockingSend(); + const { upload, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile()]); + resolve({ id: 42 }); + await uploadPromise; + flush(); + expect(files[0]!.response).toEqual({ id: 42 }); + expect(files[0]!.status).toBe("success"); + dispose(); + }); + + it("aggregate progress updates across all files during transfer", async () => { + const { send, resolve, emitProgress } = makeBlockingSend(); + const { upload, progress, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile()]); + + emitProgress(512, 1024); + flush(); + expect(progress()).toEqual({ loaded: 512, total: 1024, percentage: 50 }); + + emitProgress(1024, 1024); + flush(); + expect(progress()).toEqual({ loaded: 1024, total: 1024, percentage: 100 }); + + resolve(); + await uploadPromise; + dispose(); + }); + + it("per-file error is set and aggregate status → error when send rejects", async () => { + const boom = new Error("network failure"); + const { send, reject } = makeBlockingSend(); + const { upload, status, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile()]).catch(() => {}); + reject(boom); + await uploadPromise; + flush(); + + expect(status()).toBe("error"); + expect(files[0]!.status).toBe("error"); + expect((files[0]!.error as Error).message).toBe(boom.message); + dispose(); + }); + + it("status → aborted when abort() is called", async () => { + const { send } = makeBlockingSend(); + const { upload, abort, status, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile()]).catch(() => {}); + await Promise.resolve(); + abort(); + await uploadPromise; + flush(); + + expect(status()).toBe("aborted"); + expect(files[0]!.status).toBe("aborted"); + expect(files[0]!.error).toBeNull(); + dispose(); + }); + + it("progress resets to zero when a new upload starts", async () => { + const blocker1 = makeBlockingSend(); + const blocker2 = makeBlockingSend(); + const sendFn = vi.fn() + .mockImplementationOnce(blocker1.send) + .mockImplementationOnce(blocker2.send); + + const { upload, progress, dispose } = createRoot(dispose => ({ + ...createFileUploader(sendFn), + dispose, + })); + + const first = upload([makeUploadFile()]).catch(() => {}); + blocker1.emitProgress(1024, 1024); + flush(); + expect(progress().percentage).toBe(100); + + // Start second upload — aborts first and resets progress + upload([makeUploadFile()]).catch(() => {}); + await first; + flush(); + expect(progress()).toEqual({ loaded: 0, total: 0, percentage: 0 }); + + dispose(); + }); + + it("dispatches a separate send call per file", async () => { + const { send, resolveCall } = makeBlockingSend(); + const { upload, status, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile("a.png"), makeUploadFile("b.png")]); + await Promise.resolve(); + + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[0]![0]).toMatchObject({ name: "a.png" }); + expect(send.mock.calls[1]![0]).toMatchObject({ name: "b.png" }); + expect(files.length).toBe(2); + + resolveCall(0, "res-a"); + resolveCall(1, "res-b"); + await uploadPromise; + flush(); + + expect(status()).toBe("success"); + expect(files[0]!.response).toBe("res-a"); + expect(files[1]!.response).toBe("res-b"); + dispose(); + }); + + it("partial failure: one file errors while the other succeeds", async () => { + const { send, resolveCall, rejectCall } = makeBlockingSend(); + const { upload, status, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const boom = new Error("file b failed"); + const uploadPromise = upload([makeUploadFile("a.png"), makeUploadFile("b.png")]); + await Promise.resolve(); + + resolveCall(0, "ok-a"); + rejectCall(1, boom); + await uploadPromise; + flush(); + + expect(status()).toBe("error"); + expect(files[0]!.status).toBe("success"); + expect(files[0]!.response).toBe("ok-a"); + expect(files[1]!.status).toBe("error"); + expect((files[1]!.error as Error).message).toBe(boom.message); + dispose(); + }); + + it("per-file progress tracked independently for multiple files", async () => { + const { send, resolveCall, emitProgressCall } = makeBlockingSend(); + const { upload, progress, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile("a.png"), makeUploadFile("b.png")]); + await Promise.resolve(); + + emitProgressCall(0, 256, 1024); // file a: 25% + emitProgressCall(1, 512, 1024); // file b: 50% + flush(); + + expect(files[0]!.progress.percentage).toBe(25); + expect(files[1]!.progress.percentage).toBe(50); + // aggregate: (256+512) / (1024+1024) = 768/2048 = 37.5 → 38% + expect(progress().percentage).toBe(38); + + resolveCall(0); + resolveCall(1); + await uploadPromise; + dispose(); + }); + + it("clearFiles empties the files list and aborts in-flight uploads", async () => { + const { send } = makeBlockingSend(); + const { upload, files, status, clearFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile("a.png"), makeUploadFile("b.png")]).catch(() => {}); + await Promise.resolve(); + flush(); + expect(files.length).toBe(2); + + clearFiles(); + await uploadPromise; + flush(); + expect(files.length).toBe(0); + expect(status()).toBe("idle"); + dispose(); + }); + + it("removeFile removes a single entry and aborts in-flight uploads", async () => { + const { send } = makeBlockingSend(); + const { upload, files, removeFile, dispose } = createRoot(dispose => ({ + ...createFileUploader(send), + dispose, + })); + + const uploadPromise = upload([makeUploadFile("a.png"), makeUploadFile("b.png")]).catch(() => {}); + await Promise.resolve(); + flush(); + expect(files.length).toBe(2); + + removeFile("a.png"); + await uploadPromise; + flush(); + expect(files.length).toBe(1); + expect(files[0]!.file.name).toBe("b.png"); + dispose(); + }); + + it("clearFiles on an already-empty list is a no-op", () => { + const { status, clearFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(makeBlockingSend().send), + dispose, + })); + clearFiles(); + expect(status()).toBe("idle"); + dispose(); + }); +}); + +// ── createFileUploader — XHR path ─────────────────────────────────────────────── + +function makeMockXhr() { + const uploadListeners: Record void> = {}; + const xhrListeners: Record void> = {}; + + const xhr = { + open: vi.fn(), + send: vi.fn(), + setRequestHeader: vi.fn(), + abort: vi.fn(() => xhrListeners["abort"]?.(new Event("abort"))), + status: 200, + statusText: "OK", + responseText: '{"ok":true}', + upload: { + addEventListener: vi.fn((type: string, fn: (e: ProgressEvent) => void) => { + uploadListeners[type] = fn; + }), + }, + addEventListener: vi.fn((type: string, fn: (e: Event) => void) => { + xhrListeners[type] = fn; + }), + }; + + return { + xhr, + triggerProgress: (loaded: number, total: number) => + uploadListeners["progress"]?.({ lengthComputable: true, loaded, total } as ProgressEvent), + triggerLoad: () => xhrListeners["load"]?.(new Event("load")), + triggerError: () => xhrListeners["error"]?.(new Event("error")), + triggerAbort: () => xhrListeners["abort"]?.(new Event("abort")), + }; +} + +describe("createFileUploader — XHR", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("opens a POST to the provided URL and sends FormData", () => { + const { xhr } = makeMockXhr(); + vi.stubGlobal("XMLHttpRequest", vi.fn(() => xhr)); + + const { upload } = createRoot(dispose => ({ + ...createFileUploader(fileSender("/api/upload")), + dispose, + })); + + upload([makeUploadFile()]).catch(() => {}); + + expect(xhr.open).toHaveBeenCalledWith("POST", "/api/upload"); + expect(xhr.send).toHaveBeenCalledWith(expect.any(FormData)); + }); + + it("resolves with parsed JSON on 200 load", async () => { + const mock = makeMockXhr(); + vi.stubGlobal("XMLHttpRequest", vi.fn(() => mock.xhr)); + + const { upload, status, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(fileSender("/api/upload")), + dispose, + })); + + const uploadPromise = upload([makeUploadFile()]); + mock.triggerLoad(); + await uploadPromise; + flush(); + + expect(status()).toBe("success"); + expect(files[0]!.response).toEqual({ ok: true }); + dispose(); + }); + + it("rejects with an HTTP error for non-2xx responses", async () => { + const mock = makeMockXhr(); + mock.xhr.status = 413; + mock.xhr.statusText = "Payload Too Large"; + vi.stubGlobal("XMLHttpRequest", vi.fn(() => mock.xhr)); + + const { upload, status, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(fileSender("/api/upload")), + dispose, + })); + + const uploadPromise = upload([makeUploadFile()]).catch(() => {}); + mock.triggerLoad(); + await uploadPromise; + flush(); + + expect(status()).toBe("error"); + expect((files[0]!.error as Error).message).toMatch("HTTP 413"); + dispose(); + }); + + it("sets network error status when XHR fires error", async () => { + const mock = makeMockXhr(); + vi.stubGlobal("XMLHttpRequest", vi.fn(() => mock.xhr)); + + const { upload, status, files, dispose } = createRoot(dispose => ({ + ...createFileUploader(fileSender("/api/upload")), + dispose, + })); + + const uploadPromise = upload([makeUploadFile()]).catch(() => {}); + mock.triggerError(); + await uploadPromise; + flush(); + + expect(status()).toBe("error"); + expect((files[0]!.error as Error).message).toMatch("Network error"); + dispose(); + }); + + it("forwards progress events to the progress signal", async () => { + const mock = makeMockXhr(); + vi.stubGlobal("XMLHttpRequest", vi.fn(() => mock.xhr)); + + const { upload, progress, dispose } = createRoot(dispose => ({ + ...createFileUploader(fileSender("/api/upload")), + dispose, + })); + + upload([makeUploadFile()]).catch(() => {}); + + mock.triggerProgress(256, 1024); + flush(); + expect(progress()).toEqual({ loaded: 256, total: 1024, percentage: 25 }); + + mock.triggerLoad(); + dispose(); + }); + + it("sets custom headers on the XHR request", () => { + const { xhr } = makeMockXhr(); + vi.stubGlobal("XMLHttpRequest", vi.fn(() => xhr)); + + const { upload } = createRoot(dispose => ({ + ...createFileUploader(fileSender("/api/upload", { headers: { "X-Auth": "token123" } })), + dispose, + })); + + upload([makeUploadFile()]).catch(() => {}); + expect(xhr.setRequestHeader).toHaveBeenCalledWith("X-Auth", "token123"); + }); +}); From 2aea0a0d0d9f29968811612a392b17f434cddff0 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 24 May 2026 09:07:20 -0400 Subject: [PATCH 06/13] Beta 14 and add missing isDragging signal --- .changeset/upload-solid-2-migration.md | 6 +++--- packages/upload/package.json | 8 ++++---- packages/upload/src/createDropzone.ts | 5 +++-- pnpm-lock.yaml | 8 ++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.changeset/upload-solid-2-migration.md b/.changeset/upload-solid-2-migration.md index 55476d08c..8df0b2847 100644 --- a/.changeset/upload-solid-2-migration.md +++ b/.changeset/upload-solid-2-migration.md @@ -2,9 +2,9 @@ "@solid-primitives/upload": minor --- -Migrate to Solid.js 2.0 (beta.7) +Migrate to Solid.js 2.0 (beta.14) -- Updated peer dependencies to `solid-js@^2.0.0-beta.7` and `@solidjs/web@^2.0.0-beta.7` +- Updated peer dependencies to `solid-js@^2.0.0-beta.14` and `@solidjs/web@^2.0.0-beta.14` - `isServer` is now imported from `@solidjs/web` (moved out of `solid-js/web`) -- `createDropzone`: replaced `onMount`/`onCleanup` with `onSettled` (returns cleanup function) per Solid 2.0 lifecycle API +- `createDropzone`: now returns a `setRef` ref callback; event listeners are attached when the ref is assigned and cleaned up via `onCleanup` registered through `runWithOwner` back into the reactive owner scope - `fileUploader`: replaced the `use:fileUploader` directive (removed in Solid 2.0) with a **ref callback factory** — use `ref={fileUploader(opts)}` instead of `use:fileUploader={opts}` diff --git a/packages/upload/package.json b/packages/upload/package.json index 63e0da34c..2dfaa757a 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -53,12 +53,12 @@ }, "dependencies": {}, "peerDependencies": { - "@solidjs/web": "^2.0.0-beta.10", - "solid-js": "^2.0.0-beta.10" + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" }, "typesVersions": {}, "devDependencies": { - "@solidjs/web": "2.0.0-beta.10", - "solid-js": "2.0.0-beta.10" + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" } } diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 78cd35e51..8938bcfca 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -60,17 +60,17 @@ function createDropzone( }; const onDragStart = (event: DragEvent) => { - setIsDragging(true); void runCallback(options?.onDragStart, transformFiles(event.dataTransfer?.files || null)); }; const onDragEnd = (event: DragEvent) => { - setIsDragging(false); void runCallback(options?.onDragEnd, transformFiles(event.dataTransfer?.files || null)); }; const onDragEnter = (event: DragEvent) => { + setIsDragging(true); void runCallback(options?.onDragEnter, transformFiles(event.dataTransfer?.files || null)); }; const onDragLeave = (event: DragEvent) => { + setIsDragging(false); void runCallback(options?.onDragLeave, transformFiles(event.dataTransfer?.files || null)); }; const onDragOver = (event: DragEvent) => { @@ -82,6 +82,7 @@ function createDropzone( }; const onDrop = (event: DragEvent) => { event.preventDefault(); + setIsDragging(false); const parsedFiles = transformFiles(event.dataTransfer?.files || null); setFiles(parsedFiles); setError(null); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92438ea32..9bd9db82f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1073,11 +1073,11 @@ importers: packages/upload: devDependencies: '@solidjs/web': - specifier: 2.0.0-beta.10 - version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) solid-js: - specifier: 2.0.0-beta.10 - version: 2.0.0-beta.10 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/utils: devDependencies: From 93986822b45cf51d51eb009625f1ebc00c1fb3d4 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 24 May 2026 09:09:21 -0400 Subject: [PATCH 07/13] Reset file input value to allow re-selecting the same file. --- packages/upload/src/fileUploader.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/upload/src/fileUploader.ts b/packages/upload/src/fileUploader.ts index db5e47924..b9a141da0 100644 --- a/packages/upload/src/fileUploader.ts +++ b/packages/upload/src/fileUploader.ts @@ -29,6 +29,8 @@ export const fileUploader = (options: FileUploaderDirective) => { } else { throw err; } + } finally { + target.value = ""; } }; From bd4be20af589a63ae83b689791b448cede995979 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 24 May 2026 09:12:25 -0400 Subject: [PATCH 08/13] Adjust tests to prevent leaking --- packages/upload/test/index.test.tsx | 63 ++++++++++++++++------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index e558fe834..d0ebc6b5f 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -394,12 +394,12 @@ describe("createDropzone", () => { describe("fileUploader", () => { it("returns a function (ref callback)", () => { - const ref = createRoot(dispose => { - const r = fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }); - dispose(); - return r; - }); + const { ref, dispose } = createRoot(dispose => ({ + ref: fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }), + dispose, + })); expect(typeof ref).toBe("function"); + dispose(); }); it("attaches a change listener to the element", () => { @@ -407,14 +407,14 @@ describe("fileUploader", () => { input.type = "file"; const addSpy = vi.spyOn(input, "addEventListener"); - const ref = createRoot(dispose => { - const r = fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }); - dispose(); - return r; + const dispose = createRoot(dispose => { + const ref = fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }); + ref(input); + return dispose; }); - ref(input); expect(addSpy).toHaveBeenCalledWith("change", expect.any(Function)); + dispose(); }); it("calls setFiles and userCallback on change event", async () => { @@ -423,13 +423,12 @@ describe("fileUploader", () => { const setFiles = vi.fn(); const userCallback = vi.fn(); - const ref = createRoot(dispose => { - const r = fileUploader({ userCallback, setFiles }); - dispose(); - return r; + const dispose = createRoot(dispose => { + const ref = fileUploader({ userCallback, setFiles }); + ref(input); + return dispose; }); - ref(input); dispatchChange(input, makeFileList(makeFile("upload.png"))); await Promise.resolve(); @@ -438,6 +437,7 @@ describe("fileUploader", () => { expect(uploaded).toHaveLength(1); expect(uploaded[0]!.name).toBe("upload.png"); expect(userCallback).toHaveBeenCalledOnce(); + dispose(); }); it("calls onError when userCallback throws", async () => { @@ -446,24 +446,24 @@ describe("fileUploader", () => { input.type = "file"; const onError = vi.fn(); - const ref = createRoot(dispose => { - const r = fileUploader({ + const dispose = createRoot(dispose => { + const ref = fileUploader({ userCallback: async () => { throw boom; }, setFiles: vi.fn(), onError, }); - dispose(); - return r; + ref(input); + return dispose; }); - ref(input); dispatchChange(input, makeFileList(makeFile())); await Promise.resolve(); await Promise.resolve(); expect(onError).toHaveBeenCalledWith(boom); + dispose(); }); }); @@ -801,19 +801,21 @@ function makeMockXhr() { describe("createFileUploader — XHR", () => { afterEach(() => vi.unstubAllGlobals()); - it("opens a POST to the provided URL and sends FormData", () => { - const { xhr } = makeMockXhr(); + it("opens a POST to the provided URL and sends FormData", async () => { + const { xhr, triggerLoad } = makeMockXhr(); vi.stubGlobal("XMLHttpRequest", vi.fn(() => xhr)); - const { upload } = createRoot(dispose => ({ + const { upload, dispose } = createRoot(dispose => ({ ...createFileUploader(fileSender("/api/upload")), dispose, })); - upload([makeUploadFile()]).catch(() => {}); - + const uploadPromise = upload([makeUploadFile()]); expect(xhr.open).toHaveBeenCalledWith("POST", "/api/upload"); expect(xhr.send).toHaveBeenCalledWith(expect.any(FormData)); + triggerLoad(); + await uploadPromise; + dispose(); }); it("resolves with parsed JSON on 200 load", async () => { @@ -894,16 +896,19 @@ describe("createFileUploader — XHR", () => { dispose(); }); - it("sets custom headers on the XHR request", () => { - const { xhr } = makeMockXhr(); + it("sets custom headers on the XHR request", async () => { + const { xhr, triggerLoad } = makeMockXhr(); vi.stubGlobal("XMLHttpRequest", vi.fn(() => xhr)); - const { upload } = createRoot(dispose => ({ + const { upload, dispose } = createRoot(dispose => ({ ...createFileUploader(fileSender("/api/upload", { headers: { "X-Auth": "token123" } })), dispose, })); - upload([makeUploadFile()]).catch(() => {}); + const uploadPromise = upload([makeUploadFile()]); expect(xhr.setRequestHeader).toHaveBeenCalledWith("X-Auth", "token123"); + triggerLoad(); + await uploadPromise; + dispose(); }); }); From b754ce25bf24ee69630969538ff1804477100372 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 24 May 2026 09:32:11 -0400 Subject: [PATCH 09/13] Added test for element mounting and used event-listener --- packages/upload/package.json | 4 ++- packages/upload/src/createDropzone.ts | 40 ++++++++++----------------- packages/upload/test/index.test.tsx | 35 +++++++++++++++++++++++ packages/upload/tsconfig.json | 6 +++- pnpm-lock.yaml | 4 +++ 5 files changed, 61 insertions(+), 28 deletions(-) diff --git a/packages/upload/package.json b/packages/upload/package.json index 2dfaa757a..0797becc1 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -51,7 +51,9 @@ "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, - "dependencies": {}, + "dependencies": { + "@solid-primitives/event-listener": "workspace:*" + }, "peerDependencies": { "@solidjs/web": "^2.0.0-beta.14", "solid-js": "^2.0.0-beta.14" diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 8938bcfca..72538f1b0 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -1,5 +1,6 @@ -import { createSignal, getOwner, onCleanup, runWithOwner } from "solid-js"; +import { createSignal, flush } from "solid-js"; import { isServer } from "@solidjs/web"; +import { createEventListenerMap } from "@solid-primitives/event-listener"; import { transformFiles } from "./helpers.js"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; @@ -45,9 +46,6 @@ function createDropzone( const [isLoading, setIsLoading] = createSignal(false); const [isDragging, setIsDragging] = createSignal(false); - // Capture owner in the Phase-1 (owned) scope so cleanup can be registered later - const owner = getOwner(); - const runCallback = async ( callback: ((files: UploadFile[]) => void | Promise) | undefined, parsedFiles: UploadFile[], @@ -98,29 +96,19 @@ function createDropzone( })(); }; - // setRef is the Phase-2 ref callback: called synchronously when the element is created. - // Listeners are attached immediately; cleanup is registered back in the owner scope. - const setRef = (ref: T) => { - ref.addEventListener("dragstart", onDragStart); - ref.addEventListener("dragenter", onDragEnter); - ref.addEventListener("dragend", onDragEnd); - ref.addEventListener("dragleave", onDragLeave); - ref.addEventListener("dragover", onDragOver); - ref.addEventListener("drag", onDrag); - ref.addEventListener("drop", onDrop); + const [refTarget, setRefTarget] = createSignal(undefined); - runWithOwner(owner, () => { - onCleanup(() => { - ref.removeEventListener("dragstart", onDragStart); - ref.removeEventListener("dragenter", onDragEnter); - ref.removeEventListener("dragend", onDragEnd); - ref.removeEventListener("dragleave", onDragLeave); - ref.removeEventListener("dragover", onDragOver); - ref.removeEventListener("drag", onDrag); - ref.removeEventListener("drop", onDrop); - }); - }); - }; + createEventListenerMap(refTarget as () => T, { + dragstart: onDragStart, + dragenter: onDragEnter, + dragend: onDragEnd, + dragleave: onDragLeave, + dragover: onDragOver, + drag: onDrag, + drop: onDrop, + }); + + const setRef = (ref: T) => { setRefTarget(() => ref); flush(); }; const removeFile = (fileName: string) => { setFiles(prev => prev.filter(f => f.name !== fileName)); diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index d0ebc6b5f..2642f0d8f 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -360,6 +360,41 @@ describe("createDropzone", () => { dispose(); }); + it("re-assigning setRef detaches listeners from the previous element", () => { + const { setRef, isDragging, dispose } = createRoot(dispose => ({ + ...createDropzone(), + dispose, + })); + + const el1 = document.createElement("div"); + const el2 = document.createElement("div"); + const removeSpy = vi.spyOn(el1, "removeEventListener"); + + setRef(el1); + el1.dispatchEvent(new Event("dragenter")); + flush(); + expect(isDragging()).toBe(true); + el1.dispatchEvent(new Event("dragleave")); + flush(); + expect(isDragging()).toBe(false); + + // Re-assign — el1 listeners should be removed, el2 should become active + setRef(el2); + expect(removeSpy).toHaveBeenCalled(); + + // el1 events no longer affect state + el1.dispatchEvent(new Event("dragenter")); + flush(); + expect(isDragging()).toBe(false); + + // el2 events now drive state + el2.dispatchEvent(new Event("dragenter")); + flush(); + expect(isDragging()).toBe(true); + + dispose(); + }); + it("error() captures a thrown onDrop callback", async () => { const boom = new Error("drop failed"); const { error, setRef, dispose } = createRoot(dispose => ({ diff --git a/packages/upload/tsconfig.json b/packages/upload/tsconfig.json index 38c71ce71..9cfacb5f9 100644 --- a/packages/upload/tsconfig.json +++ b/packages/upload/tsconfig.json @@ -5,7 +5,11 @@ "outDir": "dist", "rootDir": "src" }, - "references": [], + "references": [ + { + "path": "../event-listener" + } + ], "include": [ "src" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bd9db82f..3257c9147 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1071,6 +1071,10 @@ importers: version: 2.0.0-beta.14 packages/upload: + dependencies: + '@solid-primitives/event-listener': + specifier: workspace:* + version: link:../event-listener devDependencies: '@solidjs/web': specifier: 2.0.0-beta.14 From 2e8620ec8e770bc39949bad2ac019b61adbf580b Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 24 May 2026 09:43:30 -0400 Subject: [PATCH 10/13] Added dropzone factory --- packages/upload/README.md | 53 +++++++++++++----- packages/upload/package.json | 3 +- packages/upload/src/createDropzone.ts | 28 +++++++--- packages/upload/src/index.ts | 2 +- packages/upload/src/types.ts | 2 +- packages/upload/test/index.test.tsx | 78 ++++++++++++++++++++++----- 6 files changed, 131 insertions(+), 35 deletions(-) diff --git a/packages/upload/README.md b/packages/upload/README.md index eaede9b58..390415ab2 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -15,6 +15,7 @@ Primitives for file picking, drag-and-drop zones, and XHR uploads with progress - [`fileSender`](#filesender) — XHR transport factory for `createFileUploader` (tree-shakeable) - [`fileUploader`](#fileuploader) — ref callback factory for `` elements - [`createDropzone`](#createdropzone) — reactive drag-and-drop zone with full drag-event callbacks +- [`dropzone`](#dropzone) — ref callback factory variant of `createDropzone` ## Installation @@ -218,18 +219,18 @@ If `onError` is omitted, a rejection from `userCallback` propagates as an unhand ## `createDropzone` -A reactive drag-and-drop zone. Attach it to any element via the `setRef` ref callback and respond to the full set of drag lifecycle events. +A reactive drag-and-drop zone. Attach it to any element via the `ref` callback and respond to the full set of drag lifecycle events. ```tsx import { createDropzone, createFileUploader, fileSender } from "@solid-primitives/upload"; const { upload, progress, status } = createFileUploader(fileSender("/api/upload")); -const { setRef, files, isDragging, error } = createDropzone({ +const { ref, files, isDragging, error } = createDropzone({ onDrop: files => upload(files), });
void` | Ref callback — pass to the `ref` prop of the drop target element | -| `files` | `Accessor` | Reactive list of the most recently dropped files | -| `error` | `Accessor` | Error thrown by the last `onDrop` callback; `null` if none | -| `isLoading` | `Accessor` | `true` while the `onDrop` callback is pending | -| `isDragging` | `Accessor` | `true` while a drag is active over the element | -| `removeFile` | `(fileName: string) => void` | Removes a single file from the list by name | -| `clearFiles` | `() => void` | Clears all dropped files | +| Name | Type | Description | +| ------------ | ---------------------------- | ---------------------------------------------------------------- | +| `ref` | `(el: T) => void` | Ref callback — pass to the `ref` prop of the drop target element | +| `files` | `Accessor` | Reactive list of the most recently dropped files | +| `error` | `Accessor` | Error thrown by the last `onDrop` callback; `null` if none | +| `isLoading` | `Accessor` | `true` while the `onDrop` callback is pending | +| `isDragging` | `Accessor` | `true` while a drag is active over the element | +| `removeFile` | `(fileName: string) => void` | Removes a single file from the list by name | +| `clearFiles` | `() => void` | Clears all dropped files | **Options (all optional):** @@ -272,6 +273,34 @@ const { setRef, files, isDragging, error } = createDropzone({ All callbacks have signature `(files: UploadFile[]) => void | Promise`. `isLoading` tracks only the `onDrop` callback — drag-movement events are fire-and-forget. +## `dropzone` + +A ref callback factory variant of `createDropzone`. Returns a single value that is both the ref callback and the reactive state object — use it directly as a `ref` while reading `.files`, `.isDragging`, etc. from the same reference. Mirrors the `fileUploader` pattern. + +```tsx +import { dropzone, createFileUploader, fileSender } from "@solid-primitives/upload"; + +const { upload, progress, status } = createFileUploader(fileSender("/api/upload")); + +
upload(files) + })} + style={{ + background: dz.isDragging() ? "lightblue" : "lightgray", + padding: "2rem", + border: "2px dashed #999", + }} +> + + Uploading… {progress().percentage}% + + {file =>

{file.name}

}
+
+``` + +The returned value is a function (the ref callback) with all `createDropzone` state properties attached directly to it — `files`, `error`, `isLoading`, `isDragging`, `removeFile`, and `clearFiles`. Accepts the same `DropzoneOptions` as `createDropzone`. + ## Types ```ts diff --git a/packages/upload/package.json b/packages/upload/package.json index 0797becc1..3466cbbb7 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -17,7 +17,8 @@ "createFileUploader", "fileSender", "fileUploader", - "createDropzone" + "createDropzone", + "dropzone" ], "category": "Browser APIs" }, diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 72538f1b0..498f1867e 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -7,7 +7,7 @@ import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; /** * Primitive to make working with dropzones easier. * - * @returns `setRef` + * @returns `ref` * @returns `files` * @returns `error` - Reactive error from the last drag callback, cleared on next drop * @returns `isLoading` - True while the `onDrop` callback is pending @@ -17,7 +17,7 @@ import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; * * @example * ```ts - * const { setRef: dropzoneRef, files: droppedFiles, error } = createDropzone({ + * const { ref: dropzoneRef, files: droppedFiles, error } = createDropzone({ * onDrop: async files => { * await doStuff(2); * files.forEach(f => console.log(f)); @@ -32,7 +32,7 @@ function createDropzone( ): Dropzone { if (isServer) { return { - setRef: () => {}, + ref: () => {}, files: () => [], error: () => null, isLoading: () => false, @@ -108,7 +108,7 @@ function createDropzone( drop: onDrop, }); - const setRef = (ref: T) => { setRefTarget(() => ref); flush(); }; + const ref = (el: T) => { setRefTarget(() => el); flush(); }; const removeFile = (fileName: string) => { setFiles(prev => prev.filter(f => f.name !== fileName)); @@ -119,7 +119,7 @@ function createDropzone( }; return { - setRef, + ref, files, error, isLoading, @@ -129,4 +129,20 @@ function createDropzone( }; } -export { createDropzone }; +/** + * Ref callback factory for dropzone elements. Returns a single value that is + * both a ref callback and a reactive state object — use it directly as a `ref` + * while reading `.files`, `.isDragging`, etc. from the same reference. + * + * @example + * ```tsx + * const dz = dropzone({ onDrop: handleFiles }); + *
+ * ``` + */ +function dropzone(options?: DropzoneOptions) { + const { ref, ...state } = createDropzone(options); + return Object.assign(ref, state); +} + +export { createDropzone, dropzone }; diff --git a/packages/upload/src/index.ts b/packages/upload/src/index.ts index daf0c9766..60dc4812c 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -2,6 +2,6 @@ export { createFilePicker } from "./createFilePicker.js"; export { createFileUploader } from "./createFileUploader.js"; export { fileSender } from "./fileSender.js"; export type { FileSenderOptions } from "./fileSender.js"; -export { createDropzone } from "./createDropzone.js"; +export { createDropzone, dropzone } from "./createDropzone.js"; export { fileUploader } from "./fileUploader.js"; export * from "./types.js"; diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index 430e89bbb..c7f77bd3c 100644 --- a/packages/upload/src/types.ts +++ b/packages/upload/src/types.ts @@ -37,7 +37,7 @@ export type FileUploaderDirective = { }; export interface Dropzone { - setRef: (ref: T) => void; + ref: (el: T) => void; files: Accessor; error: Accessor; isLoading: Accessor; diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 2642f0d8f..e9b5bbc89 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { createRoot, flush } from "solid-js"; -import { createFilePicker, createFileUploader, fileSender, createDropzone, fileUploader } from "../src/index.js"; +import { createFilePicker, createFileUploader, fileSender, createDropzone, dropzone, fileUploader } from "../src/index.js"; import { transformFiles } from "../src/helpers.js"; import type { UploadFile } from "../src/types.js"; @@ -264,12 +264,12 @@ describe("createDropzone", () => { dispose(); }); - it("exposes setRef, removeFile, clearFiles", () => { - const { setRef, removeFile, clearFiles, dispose } = createRoot(dispose => ({ + it("exposes ref, removeFile, clearFiles", () => { + const { ref, removeFile, clearFiles, dispose } = createRoot(dispose => ({ ...createDropzone(), dispose, })); - expect(typeof setRef).toBe("function"); + expect(typeof ref).toBe("function"); expect(typeof removeFile).toBe("function"); expect(typeof clearFiles).toBe("function"); dispose(); @@ -311,14 +311,14 @@ describe("createDropzone", () => { // Capture isLoading via closure — onDrop is passed before the primitive returns let getIsLoading!: () => boolean; - const { setRef, dispose } = createRoot(dispose => { + const { ref, dispose } = createRoot(dispose => { const dz = createDropzone({ onDrop: () => blocker }); getIsLoading = dz.isLoading; return { ...dz, dispose }; }); const div = document.createElement("div"); - setRef(div); + ref(div); const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); Object.defineProperty(dropEvent, "dataTransfer", { @@ -339,13 +339,13 @@ describe("createDropzone", () => { }); it("isLoading is false after onDrop throws", async () => { - const { isLoading, setRef, dispose } = createRoot(dispose => ({ + const { isLoading, ref, dispose } = createRoot(dispose => ({ ...createDropzone({ onDrop: async () => { throw new Error("oops"); } }), dispose, })); const div = document.createElement("div"); - setRef(div); + ref(div); const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); Object.defineProperty(dropEvent, "dataTransfer", { @@ -360,8 +360,8 @@ describe("createDropzone", () => { dispose(); }); - it("re-assigning setRef detaches listeners from the previous element", () => { - const { setRef, isDragging, dispose } = createRoot(dispose => ({ + it("re-assigning ref detaches listeners from the previous element", () => { + const { ref, isDragging, dispose } = createRoot(dispose => ({ ...createDropzone(), dispose, })); @@ -370,7 +370,7 @@ describe("createDropzone", () => { const el2 = document.createElement("div"); const removeSpy = vi.spyOn(el1, "removeEventListener"); - setRef(el1); + ref(el1); el1.dispatchEvent(new Event("dragenter")); flush(); expect(isDragging()).toBe(true); @@ -379,7 +379,7 @@ describe("createDropzone", () => { expect(isDragging()).toBe(false); // Re-assign — el1 listeners should be removed, el2 should become active - setRef(el2); + ref(el2); expect(removeSpy).toHaveBeenCalled(); // el1 events no longer affect state @@ -397,7 +397,7 @@ describe("createDropzone", () => { it("error() captures a thrown onDrop callback", async () => { const boom = new Error("drop failed"); - const { error, setRef, dispose } = createRoot(dispose => ({ + const { error, ref, dispose } = createRoot(dispose => ({ ...createDropzone({ onDrop: async () => { throw boom; @@ -407,7 +407,7 @@ describe("createDropzone", () => { })); const div = document.createElement("div"); - setRef(div); + ref(div); // jsdom does not implement DragEvent — dispatch a plain Event with stubbed dataTransfer const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); @@ -425,6 +425,56 @@ describe("createDropzone", () => { }); }); +// ── dropzone ref callback factory ──────────────────────────────────────────── + +describe("dropzone", () => { + it("returns a callable ref with state properties attached", () => { + const { dz, dispose } = createRoot(dispose => ({ + dz: dropzone(), + dispose, + })); + expect(typeof dz).toBe("function"); + expect(typeof dz.files).toBe("function"); + expect(typeof dz.isDragging).toBe("function"); + expect(typeof dz.isLoading).toBe("function"); + expect(typeof dz.error).toBe("function"); + expect(typeof dz.removeFile).toBe("function"); + expect(typeof dz.clearFiles).toBe("function"); + dispose(); + }); + + it("initial state matches createDropzone defaults", () => { + const { dz, dispose } = createRoot(dispose => ({ + dz: dropzone(), + dispose, + })); + expect(dz.files()).toEqual([]); + expect(dz.error()).toBeNull(); + expect(dz.isLoading()).toBe(false); + expect(dz.isDragging()).toBe(false); + dispose(); + }); + + it("works as a ref — drag events update isDragging", () => { + const { dz, dispose } = createRoot(dispose => ({ + dz: dropzone(), + dispose, + })); + const el = document.createElement("div"); + dz(el); + + el.dispatchEvent(new Event("dragenter")); + flush(); + expect(dz.isDragging()).toBe(true); + + el.dispatchEvent(new Event("dragleave")); + flush(); + expect(dz.isDragging()).toBe(false); + + dispose(); + }); +}); + // ── fileUploader ref callback ───────────────────────────────────────────────── describe("fileUploader", () => { From e379a1c6efd5d7e373f68bcbff9f8aedf1630e9c Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 24 May 2026 09:45:08 -0400 Subject: [PATCH 11/13] Catch synchronous send() failures per file. --- packages/upload/src/createFileUploader.ts | 16 ++++++++++------ packages/upload/src/fileSender.ts | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/upload/src/createFileUploader.ts b/packages/upload/src/createFileUploader.ts index 12331fa52..8754c79f1 100644 --- a/packages/upload/src/createFileUploader.ts +++ b/packages/upload/src/createFileUploader.ts @@ -68,12 +68,16 @@ function createFileUploader(send: SendFunction): FileUploader { const settled = await Promise.allSettled( uploadFiles.map((uploadFile, i) => - send( - uploadFile, - (p: UploadProgress) => { - if (generation === thisGen) setFiles(s => { s[i]!.progress = p; }); - }, - controllers[i]!.signal, + new Promise(resolve => + resolve( + send( + uploadFile, + (p: UploadProgress) => { + if (generation === thisGen) setFiles(s => { s[i]!.progress = p; }); + }, + controllers[i]!.signal, + ), + ), ).then( result => { if (generation === thisGen) { diff --git a/packages/upload/src/fileSender.ts b/packages/upload/src/fileSender.ts index 6a0e2d4ca..e01e9b02f 100644 --- a/packages/upload/src/fileSender.ts +++ b/packages/upload/src/fileSender.ts @@ -57,6 +57,7 @@ export function fileSender(url: string, options?: FileSenderOptions): SendFuncti xhr.open("POST", url); for (const [k, v] of Object.entries(headers)) { + if (formData instanceof FormData && k.toLowerCase() === "content-type") continue; xhr.setRequestHeader(k, v); } xhr.send(formData); From 665f0058736db4d48a89cbae66f8051dec27494c Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 24 May 2026 09:56:45 -0400 Subject: [PATCH 12/13] Add note about duplicate files --- packages/upload/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/upload/README.md b/packages/upload/README.md index 390415ab2..b62b1b32d 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -60,6 +60,8 @@ selectFiles(async files => { | `removeFile` | `(fileName: string) => void` | Removes a single file from the list by name | | `clearFiles` | `() => void` | Clears all selected files | +> **Note:** `removeFile` matches by file name. If the list contains duplicate file names, only the first match is removed. + **Options:** | Option | Type | Default | Description | @@ -119,6 +121,8 @@ const results = await upload(myFiles); | `removeFile` | `(fileName: string) => void` | Remove one entry by name; aborts all in-flight uploads | | `clearFiles` | `() => void` | Remove all entries; aborts all in-flight uploads | +> **Note:** `removeFile` matches by `file.name`. If a batch contains duplicate file names, only the first match is removed. Avoid batching files with duplicate names when using `removeFile`. + Each entry in `files` has the shape: ```ts @@ -259,6 +263,8 @@ const { ref, files, isDragging, error } = createDropzone({ | `removeFile` | `(fileName: string) => void` | Removes a single file from the list by name | | `clearFiles` | `() => void` | Clears all dropped files | +> **Note:** `removeFile` matches by file name. If the list contains duplicate file names, only the first match is removed. + **Options (all optional):** | Callback | Fires when… | From 56c10a218fa7e1d7396fd14c4cf6fd3bd2571cef Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:15:48 -0400 Subject: [PATCH 13/13] Added stories --- packages/upload/README.md | 5 - packages/upload/dev/index.tsx | 124 -------- packages/upload/src/helpers.ts | 8 +- packages/upload/src/types.ts | 2 +- packages/upload/stories/upload.stories.tsx | 354 +++++++++++++++++++++ 5 files changed, 357 insertions(+), 136 deletions(-) delete mode 100644 packages/upload/dev/index.tsx create mode 100644 packages/upload/stories/upload.stories.tsx diff --git a/packages/upload/README.md b/packages/upload/README.md index 898f20bd1..bb2d53805 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -362,11 +362,6 @@ type FileUploaderDirective = { All primitives are SSR-safe. On the server they return no-op stubs so components render without errors. -## Demo - -Working example: https://primitives.solidjs.community/playground/upload -Source code: https://github.com/solidjs-community/solid-primitives/blob/main/packages/upload/dev/index.tsx - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/upload/dev/index.tsx b/packages/upload/dev/index.tsx deleted file mode 100644 index cb8b02f54..000000000 --- a/packages/upload/dev/index.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { type Component, createSignal, For } from "solid-js"; - -import { createDropzone, createFileUploader, fileUploader } from "../src/index.js"; -import { doStuff } from "../src/helpers.js"; - -import type { UploadFile } from "../src/types.js"; - -const SingleFileUpload: Component = () => { - const { files, selectFiles } = createFileUploader(); - const { files: filesAsync, selectFiles: selectFilesAsync } = createFileUploader(); - - return ( -
-
-
Select a single file
- - {file =>

{file.name}

}
-
- -
-
Select a single file with async callback
- - {file =>

{file.name}

}
-
-
- ); -}; - -const MultipleFileUpload: Component = () => { - const { files, selectFiles } = createFileUploader({ - multiple: true, - accept: "image/*", - }); - - return ( -
-
Select multiple files
- - {item =>

{item.name}

}
-
- ); -}; - -const Dropzone: Component = () => { - const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({ - onDrop: async files => { - await doStuff(2); - files.forEach(f => console.log(f)); - }, - onDragOver: files => console.log("over", files.length), - }); - - return ( -
-
- Upload files using createDropzone with `div` and async callback -
-
- Dropzone -
- {file =>

{file.name}

}
-
- ); -}; - -const FileUploaderDirective: Component = () => { - const [files, setFiles] = createSignal([]); - - return ( -
-
- Upload files using fileUploader directive -
- fs.forEach(f => console.log(f)), - setFiles, - })} - /> - {file =>

{file.name}

}
-
- ); -}; - -const App: Component = () => { - return ( -
- -
- -
- -
- -
- ); -}; - -export default App; diff --git a/packages/upload/src/helpers.ts b/packages/upload/src/helpers.ts index 8f6533791..db58efbf7 100644 --- a/packages/upload/src/helpers.ts +++ b/packages/upload/src/helpers.ts @@ -1,10 +1,6 @@ -import { type FileUploaderOptions, type UploadFile } from "./types.js"; +import { type FilePickerOptions, type UploadFile } from "./types.js"; -export const doStuff = (s: number): Promise => { - return new Promise(res => setTimeout(res, s * 1000)); -}; - -export function createInputComponent({ multiple = false, accept = "" }: FileUploaderOptions) { +export function createInputComponent({ multiple = false, accept = "" }: FilePickerOptions) { const element = document.createElement("input"); element.type = "file"; element.accept = accept; diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index c7f77bd3c..3be0ab6c0 100644 --- a/packages/upload/src/types.ts +++ b/packages/upload/src/types.ts @@ -25,7 +25,7 @@ export interface FilePicker { files: Accessor; error: Accessor; isLoading: Accessor; - selectFiles: (callback: (files: UploadFile[]) => void | Promise) => void; + selectFiles: (callback?: (files: UploadFile[]) => void | Promise) => void; removeFile: (fileName: string) => void; clearFiles: () => void; } diff --git a/packages/upload/stories/upload.stories.tsx b/packages/upload/stories/upload.stories.tsx new file mode 100644 index 000000000..63e66ad90 --- /dev/null +++ b/packages/upload/stories/upload.stories.tsx @@ -0,0 +1,354 @@ +import { createSignal, For, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { + createDropzone, + createFilePicker, + createFileUploader, + fileUploader, +} from "../src/index.js"; +import type { FileUploadEntry, SendFunction, UploadFile, UploadStatus } from "../src/index.js"; +import readme from "../README.md?raw"; +import { + Alert, + Badge, + BoolRow, + Button, + ButtonRow, + Container, + ProgressBar, + Section, + StatRow, +} from "../../../.storybook/ui/index.js"; +import { colors, font, radii } from "../../../.storybook/ui/tokens.js"; + +const meta = preview.meta({ + title: "Browser APIs/Upload", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +const formatSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +// Simulates a ~1.5s upload with incremental progress events +const mockSend: SendFunction = (file, onProgress, signal) => + new Promise((resolve, reject) => { + const total = file.size || 100 * 1024; + const steps = 25; + const stepMs = 60; + let step = 0; + let cancelled = false; + + signal.addEventListener("abort", () => { + cancelled = true; + reject(new DOMException("Upload aborted", "AbortError")); + }); + + const tick = () => { + if (cancelled) return; + step++; + onProgress({ + loaded: Math.round((step / steps) * total), + total, + percentage: Math.round((step / steps) * 100), + }); + if (step < steps) setTimeout(tick, stepMs); + else resolve(); + }; + + setTimeout(tick, stepMs); + }); + +const statusVariant = (s: UploadStatus): "success" | "error" | "info" | "default" => { + if (s === "success") return "success"; + if (s === "error" || s === "aborted") return "error"; + if (s === "uploading") return "info"; + return "default"; +}; + +export const FilePickerStory = meta.story({ + name: "File selection", + parameters: { + docs: { + description: { + story: + "`createFilePicker` opens the OS file-picker and exposes `files`, `isLoading`, and `error` as reactive signals. Each new selection replaces the previous list — use `removeFile` to trim one entry or `clearFiles` to reset without re-opening the picker.", + }, + }, + }, + render: () => { + const { files, isLoading, error, selectFiles, removeFile, clearFiles } = createFilePicker({ + multiple: true, + }); + + return ( + + + + 0}> + + + + + + {String(error())} + + 0} + fallback={ + + No files selected + + } + > +
+ + {file => ( +
+ + {file.name} + + + {formatSize(file.size)} + + +
+ )} +
+
+
+
+ ); + }, +}); + +export const UploadProgressStory = meta.story({ + name: "Upload progress tracking", + parameters: { + docs: { + description: { + story: + "`createFileUploader` calls your `SendFunction` once per file in parallel, tracking per-file and aggregate state in a reactive store. Pick files, then click Upload to see status badges and the progress bar animate. Click Abort to cancel mid-upload.", + }, + }, + }, + render: () => { + const { files: picked, selectFiles } = createFilePicker({ multiple: true }); + const { upload, files: entries, progress, status, abort } = createFileUploader(mockSend); + + return ( + + + + + + + + + + + + + 0}> +
+ + {entry => ( +
+ + {entry.file.name} + + + {entry.progress.percentage}% + + {entry.status} +
+ )} +
+
+
+ 0 && (entries as FileUploadEntry[]).length === 0}> +
+ + {file => } + +
+
+
+ ); + }, +}); + +export const DropzoneStory = meta.story({ + name: "Drag-and-drop zone", + parameters: { + docs: { + description: { + story: + "`createDropzone` attaches drag lifecycle handlers to any element via its `ref` callback. `isDragging` turns true while a drag hovers over the zone; `files` updates on drop. Drag a file from your OS onto the box below to try it.", + }, + }, + }, + render: () => { + const { ref, files, isDragging, isLoading, clearFiles } = createDropzone(); + + return ( + +
+ {isDragging() ? "Release to drop" : "Drag files here"} +
+ + + 0}> +
+ + {file => } + + +
+
+
+ ); + }, +}); + +export const FileUploaderRefStory = meta.story({ + name: "Input ref wiring", + parameters: { + docs: { + description: { + story: + "`fileUploader` returns a ref callback that wires an `` into reactive state. The input can be hidden and triggered programmatically — useful when you need full control over the element's markup and styling.", + }, + }, + }, + render: () => { + const [files, setFiles] = createSignal([]); + const [error, setError] = createSignal(null); + let inputEl!: HTMLInputElement; + + const uploaderRef = fileUploader({ + userCallback: () => {}, + setFiles, + onError: setError, + }); + + return ( + + { + inputEl = el; + uploaderRef(el); + }} + style={{ display: "none" }} + /> + + + {String(error())} + + 0} + fallback={ + + No files chosen + + } + > +
+ + {file => } + +
+
+
+ ); + }, +});