(ref = el)} />
+
(ref = el)} />
{location()?.latitude}, {location()?.longitude}
diff --git a/packages/upload/README.md b/packages/upload/README.md
index c3a0227af..1609fd0ad 100644
--- a/packages/upload/README.md
+++ b/packages/upload/README.md
@@ -16,49 +16,72 @@ 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 |
+| `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)
+
+A **ref callback factory** for ` ` elements (replaces the Solid 1.x `use:fileUploader` directive).
+
+```tsx
const [files, setFiles] = createSignal([]);
+const [uploadError, setUploadError] = createSignal(null);
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)}`.
+
### [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 +89,60 @@ 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 |
+| `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 |
+
+## 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;
+ onError?: (error: unknown) => void;
+};
```
## Demo
diff --git a/packages/upload/dev/index.tsx b/packages/upload/dev/index.tsx
index 34065df6e..cb8b02f54 100644
--- a/packages/upload/dev/index.tsx
+++ b/packages/upload/dev/index.tsx
@@ -5,8 +5,6 @@ import { doStuff } from "../src/helpers.js";
import type { UploadFile } from "../src/types.js";
-fileUploader;
-
const SingleFileUpload: Component = () => {
const { files, selectFiles } = createFileUploader();
const { files: filesAsync, selectFiles: selectFilesAsync } = createFileUploader();
@@ -99,10 +97,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..6b568519a 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, 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,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")
* });
@@ -32,58 +30,64 @@ function createDropzone(
): Dropzone {
if (isServer) {
return {
- setRef: noop,
+ setRef: () => {},
files: () => [],
+ error: () => null,
isDragging: () => false,
- removeFile: noop,
- clearFiles: noop,
+ 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);
};
- onMount(() => {
- if (!ref) return;
-
- // TODO: Should event.stopPropagation() or event.preventDefault() in handlers below?
+ // 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);
@@ -92,16 +96,18 @@ function createDropzone(
ref.addEventListener("drag", onDrag as any);
ref.addEventListener("drop", onDrop as any);
- 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);
+ 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));
@@ -114,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 4edd075c2..e2c58a4de 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";
@@ -8,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`
@@ -15,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 }));
* ```
*/
@@ -27,12 +27,14 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader {
if (isServer) {
return {
files: () => [],
- selectFiles: noop,
- removeFile: noop,
- clearFiles: noop,
+ error: () => null,
+ selectFiles: () => {},
+ removeFile: () => {},
+ clearFiles: () => {},
};
}
const [files, setFiles] = createSignal([]);
+ const [error, setError] = createSignal(null);
let userCallback: UserCallback = () => {};
@@ -51,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) => {
@@ -82,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 7f2698154..0c9d1da44 100644
--- a/packages/upload/src/index.ts
+++ b/packages/upload/src/index.ts
@@ -1,43 +1,4 @@
-import { type JSX, onCleanup, onMount } from "solid-js";
-import { isServer } from "solid-js/web";
-import { transformFiles } from "./helpers.js";
-import { type FileUploaderDirective } from "./types.js";
-
-declare module "solid-js" {
- namespace JSX {
- interface Directives {
- fileUploader: FileUploaderDirective;
- }
- }
-}
-
-export const fileUploader = (element: HTMLInputElement, options: () => FileUploaderDirective) => {
- if (isServer) {
- return;
- }
- const { userCallback, setFiles } = options();
-
- 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);
- });
-};
-
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 6d1c90e17..cac65687d 100644
--- a/packages/upload/test/index.test.tsx
+++ b/packages/upload/test/index.test.tsx
@@ -1,16 +1,341 @@
-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 });
+}
+
+// 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", () => {
+ 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 and no error", () => {
+ const { files, error, dispose } = createRoot(dispose => ({
+ ...createFileUploader(),
+ dispose,
+ }));
+ expect(files()).toEqual([]);
+ expect(error()).toBeNull();
+ dispose();
+ });
+
+ it("initialises with empty file list (multiple mode)", () => {
+ const { files, dispose } = createRoot(dispose => ({
+ ...createFileUploader({ multiple: true }),
+ dispose,
+ }));
+ expect(files()).toEqual([]);
+ dispose();
+ });
+
+ it("removeFile is a no-op on an empty list", () => {
+ const { files, removeFile, dispose } = createRoot(dispose => ({
+ ...createFileUploader(),
+ dispose,
+ }));
+ removeFile("nonexistent.png");
+ 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();
+ });
+
+ 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, 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();
+ });
+
+ 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();
+ });
+
+ 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 ─────────────────────────────────────────────────
- expect(file()).toEqual([]);
- expect(files()).toEqual([]);
+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);
+ dispatchChange(input, makeFileList(makeFile("upload.png")));
+ 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();
+ });
+
+ 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);
});
});
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):