@praxisjs/composables 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/browser.d.ts +41 -18
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/browser.js +114 -112
  5. package/dist/browser.js.map +1 -1
  6. package/dist/dom.d.ts +24 -16
  7. package/dist/dom.d.ts.map +1 -1
  8. package/dist/dom.js +75 -78
  9. package/dist/dom.js.map +1 -1
  10. package/dist/list.d.ts +4 -1
  11. package/dist/list.d.ts.map +1 -1
  12. package/dist/list.js +29 -11
  13. package/dist/list.js.map +1 -1
  14. package/dist/upload.d.ts.map +1 -1
  15. package/dist/upload.js +32 -12
  16. package/dist/upload.js.map +1 -1
  17. package/dist/utilities.d.ts +13 -1
  18. package/dist/utilities.d.ts.map +1 -1
  19. package/dist/utilities.js +88 -26
  20. package/dist/utilities.js.map +1 -1
  21. package/package.json +14 -5
  22. package/src/browser.ts +129 -113
  23. package/src/dom.ts +77 -79
  24. package/src/list.ts +29 -10
  25. package/src/upload.ts +30 -11
  26. package/src/utilities.ts +101 -24
  27. package/dist/__tests__/browser.test.d.ts +0 -2
  28. package/dist/__tests__/browser.test.d.ts.map +0 -1
  29. package/dist/__tests__/browser.test.js +0 -333
  30. package/dist/__tests__/browser.test.js.map +0 -1
  31. package/dist/__tests__/dom.test.d.ts +0 -2
  32. package/dist/__tests__/dom.test.d.ts.map +0 -1
  33. package/dist/__tests__/dom.test.js +0 -416
  34. package/dist/__tests__/dom.test.js.map +0 -1
  35. package/dist/__tests__/list.test.d.ts +0 -2
  36. package/dist/__tests__/list.test.d.ts.map +0 -1
  37. package/dist/__tests__/list.test.js +0 -156
  38. package/dist/__tests__/list.test.js.map +0 -1
  39. package/dist/__tests__/upload.test.d.ts +0 -2
  40. package/dist/__tests__/upload.test.d.ts.map +0 -1
  41. package/dist/__tests__/upload.test.js +0 -531
  42. package/dist/__tests__/upload.test.js.map +0 -1
  43. package/dist/__tests__/utilities.test.d.ts +0 -2
  44. package/dist/__tests__/utilities.test.d.ts.map +0 -1
  45. package/dist/__tests__/utilities.test.js +0 -251
  46. package/dist/__tests__/utilities.test.js.map +0 -1
  47. package/src/__tests__/browser.test.ts +0 -395
  48. package/src/__tests__/dom.test.ts +0 -515
  49. package/src/__tests__/list.test.ts +0 -210
  50. package/src/__tests__/upload.test.ts +0 -639
  51. package/src/__tests__/utilities.test.ts +0 -327
  52. package/tsconfig.json +0 -8
@@ -1,639 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
- import { signal } from "@praxisjs/core/internal";
4
-
5
- import {
6
- FileSelection,
7
- DropZone,
8
- FileUpload,
9
- FilePreview,
10
- formatFileSize,
11
- validateFiles,
12
- type FileError,
13
- type UploadItem,
14
- } from "../index";
15
-
16
- function makeFile(name: string, size = 10, type = "text/plain"): File {
17
- return new File([new Uint8Array(size)], name, { type });
18
- }
19
-
20
- class FakeXHR {
21
- static instances: FakeXHR[] = [];
22
- method = "";
23
- url = "";
24
- headers: Record<string, string> = {};
25
- withCredentials = false;
26
- body: FormData | null = null;
27
- status = 0;
28
- responseText = "";
29
- aborted = false;
30
- upload: { onprogress: ((e: { lengthComputable: boolean; loaded: number; total: number }) => void) | null } = {
31
- onprogress: null,
32
- };
33
- onload: (() => void) | null = null;
34
- onerror: (() => void) | null = null;
35
- onabort: (() => void) | null = null;
36
-
37
- constructor() {
38
- FakeXHR.instances.push(this);
39
- }
40
- open(method: string, url: string) {
41
- this.method = method;
42
- this.url = url;
43
- }
44
- setRequestHeader(key: string, value: string) {
45
- this.headers[key] = value;
46
- }
47
- send(body: FormData) {
48
- this.body = body;
49
- }
50
- abort() {
51
- this.aborted = true;
52
- this.onabort?.();
53
- }
54
- respond(status: number, text = "") {
55
- this.status = status;
56
- this.responseText = text;
57
- this.onload?.();
58
- }
59
- }
60
-
61
- // ── formatFileSize ────────────────────────────────────────────────────────────
62
-
63
- describe("formatFileSize", () => {
64
- it("formats bytes, KB, MB and GB", () => {
65
- expect(formatFileSize(0)).toBe("0 B");
66
- expect(formatFileSize(512)).toBe("512 B");
67
- expect(formatFileSize(1536)).toBe("1.5 KB");
68
- expect(formatFileSize(5 * 1024 * 1024)).toBe("5.0 MB");
69
- expect(formatFileSize(2 * 1024 ** 3, 0)).toBe("2 GB");
70
- });
71
-
72
- it("returns 0 B for invalid input", () => {
73
- expect(formatFileSize(-1)).toBe("0 B");
74
- expect(formatFileSize(NaN)).toBe("0 B");
75
- });
76
- });
77
-
78
- // ── validateFiles ─────────────────────────────────────────────────────────────
79
-
80
- describe("validateFiles", () => {
81
- it("accepts everything without options", () => {
82
- const files = [makeFile("a.txt"), makeFile("b.png", 5, "image/png")];
83
- const { accepted, rejected } = validateFiles(files, 0, {});
84
- expect(accepted).toEqual(files);
85
- expect(rejected).toEqual([]);
86
- });
87
-
88
- it("matches extension, exact mime and wildcard mime tokens", () => {
89
- const opts = { accept: ".pdf, image/*, text/csv" };
90
- const pdf = makeFile("Doc.PDF", 1, "");
91
- const png = makeFile("a.png", 1, "image/png");
92
- const csv = makeFile("a.csv", 1, "text/csv");
93
- const exe = makeFile("a.exe", 1, "application/x-msdownload");
94
- const { accepted, rejected } = validateFiles([pdf, png, csv, exe], 0, opts);
95
- expect(accepted).toEqual([pdf, png, csv]);
96
- expect(rejected.map((r) => r.code)).toEqual(["type"]);
97
- });
98
-
99
- it("accepts an array for accept", () => {
100
- const { accepted } = validateFiles([makeFile("a.png", 1, "image/png")], 0, { accept: ["image/png"] });
101
- expect(accepted).toHaveLength(1);
102
- });
103
-
104
- it("rejects files above maxSize and below minSize", () => {
105
- const big = makeFile("big.txt", 100);
106
- const tiny = makeFile("tiny.txt", 1);
107
- const { rejected } = validateFiles([big, tiny], 0, { maxSize: 50, minSize: 5 });
108
- expect(rejected.map((r) => r.code)).toEqual(["max-size", "min-size"]);
109
- });
110
-
111
- it("enforces maxFiles counting already held files", () => {
112
- const files = [makeFile("a"), makeFile("b"), makeFile("c")];
113
- const { accepted, rejected } = validateFiles(files, 1, { maxFiles: 2 });
114
- expect(accepted).toEqual([files[0]]);
115
- expect(rejected.map((r) => r.code)).toEqual(["max-files", "max-files"]);
116
- });
117
- });
118
-
119
- // ── FileSelection ─────────────────────────────────────────────────────────────
120
-
121
- describe("FileSelection", () => {
122
- type View = {
123
- files: () => File[];
124
- errors: () => FileError[];
125
- add: (f: File[] | null) => void;
126
- remove: (f: File) => void;
127
- clear: () => void;
128
- open: () => void;
129
- };
130
-
131
- it("starts empty", () => {
132
- const view = new FileSelection().setup() as unknown as View;
133
- expect(view.files()).toEqual([]);
134
- expect(view.errors()).toEqual([]);
135
- });
136
-
137
- it("replaces the selection in single mode", () => {
138
- const view = new FileSelection().setup() as unknown as View;
139
- const a = makeFile("a");
140
- const b = makeFile("b");
141
- view.add([a]);
142
- view.add([b]);
143
- expect(view.files()).toEqual([b]);
144
- });
145
-
146
- it("rejects extra files in single mode", () => {
147
- const view = new FileSelection().setup() as unknown as View;
148
- view.add([makeFile("a"), makeFile("b")]);
149
- expect(view.files()).toHaveLength(1);
150
- expect(view.errors()[0].code).toBe("max-files");
151
- });
152
-
153
- it("appends in multiple mode up to maxFiles", () => {
154
- const view = new FileSelection({ maxFiles: 3 }).setup() as unknown as View;
155
- view.add([makeFile("a"), makeFile("b")]);
156
- view.add([makeFile("c"), makeFile("d")]);
157
- expect(view.files().map((f) => f.name)).toEqual(["a", "b", "c"]);
158
- expect(view.errors()).toHaveLength(1);
159
- });
160
-
161
- it("keeps the previous file when the new one is rejected", () => {
162
- const view = new FileSelection({ maxSize: 20 }).setup() as unknown as View;
163
- const ok = makeFile("ok", 10);
164
- view.add([ok]);
165
- view.add([makeFile("big", 100)]);
166
- expect(view.files()).toEqual([ok]);
167
- expect(view.errors()[0].code).toBe("max-size");
168
- });
169
-
170
- it("ignores empty input and calls onFiles with accepted files", () => {
171
- const onFiles = vi.fn();
172
- const view = new FileSelection({ multiple: true, onFiles }).setup() as unknown as View;
173
- view.add(null);
174
- view.add([]);
175
- expect(onFiles).not.toHaveBeenCalled();
176
- const a = makeFile("a");
177
- view.add([a]);
178
- expect(onFiles).toHaveBeenCalledWith([a]);
179
- });
180
-
181
- it("remove and clear update the selection", () => {
182
- const view = new FileSelection({ multiple: true }).setup() as unknown as View;
183
- const a = makeFile("a");
184
- const b = makeFile("b");
185
- view.add([a, b]);
186
- view.remove(a);
187
- expect(view.files()).toEqual([b]);
188
- view.add([makeFile("c", 1, "")]);
189
- view.clear();
190
- expect(view.files()).toEqual([]);
191
- expect(view.errors()).toEqual([]);
192
- });
193
-
194
- it("open() creates a file input configured from the options and adds the picked files", () => {
195
- let input!: HTMLInputElement;
196
- const original = document.createElement.bind(document);
197
- vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
198
- const el = original(tag);
199
- if (tag === "input") {
200
- input = el as HTMLInputElement;
201
- vi.spyOn(input, "click").mockImplementation(() => {});
202
- }
203
- return el;
204
- });
205
- const view = new FileSelection({ accept: [".png", "image/jpeg"], multiple: true }).setup() as unknown as View;
206
- view.open();
207
- expect(input.type).toBe("file");
208
- expect(input.accept).toBe(".png,image/jpeg");
209
- expect(input.multiple).toBe(true);
210
- expect(input.click).toHaveBeenCalled();
211
-
212
- const file = makeFile("a.png", 1, "image/png");
213
- Object.defineProperty(input, "files", { value: [file], configurable: true });
214
- input.dispatchEvent(new Event("change"));
215
- expect(view.files()).toEqual([file]);
216
- vi.restoreAllMocks();
217
- });
218
- });
219
-
220
- describe("FileSelection without accept", () => {
221
- it("open() leaves the input's accept attribute unset and tolerates an empty dialog", () => {
222
- let input!: HTMLInputElement;
223
- const original = document.createElement.bind(document);
224
- vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
225
- const el = original(tag);
226
- if (tag === "input") {
227
- input = el as HTMLInputElement;
228
- vi.spyOn(input, "click").mockImplementation(() => {});
229
- }
230
- return el;
231
- });
232
- const view = new FileSelection().setup() as unknown as {
233
- open: () => void;
234
- files: () => File[];
235
- };
236
- view.open();
237
- expect(input.accept).toBe("");
238
- expect(input.multiple).toBe(false);
239
- input.dispatchEvent(new Event("change"));
240
- expect(view.files()).toEqual([]);
241
- vi.restoreAllMocks();
242
- });
243
- });
244
-
245
- // ── DropZone ──────────────────────────────────────────────────────────────────
246
-
247
- describe("DropZone", () => {
248
- type View = {
249
- files: () => File[];
250
- errors: () => FileError[];
251
- dragging: () => boolean;
252
- };
253
-
254
- function dragEvent(type: string, files: File[] = [], types: string[] = ["Files"]) {
255
- const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent;
256
- Object.defineProperty(e, "dataTransfer", { value: { files, types } });
257
- return e;
258
- }
259
-
260
- it("tracks dragging through nested dragenter/dragleave pairs", () => {
261
- const el = document.createElement("div");
262
- const dz = new DropZone({ current: el });
263
- const view = dz.setup() as unknown as View;
264
- dz.onMount();
265
-
266
- el.dispatchEvent(dragEvent("dragenter"));
267
- el.dispatchEvent(dragEvent("dragenter"));
268
- expect(view.dragging()).toBe(true);
269
- el.dispatchEvent(dragEvent("dragleave"));
270
- expect(view.dragging()).toBe(true);
271
- el.dispatchEvent(dragEvent("dragleave"));
272
- expect(view.dragging()).toBe(false);
273
- });
274
-
275
- it("ignores drags that do not carry files", () => {
276
- const el = document.createElement("div");
277
- const dz = new DropZone({ current: el });
278
- const view = dz.setup() as unknown as View;
279
- dz.onMount();
280
- const e = dragEvent("dragenter", [], ["text/plain"]);
281
- el.dispatchEvent(e);
282
- expect(view.dragging()).toBe(false);
283
- expect(e.defaultPrevented).toBe(false);
284
- });
285
-
286
- it("prevents default on dragover so the drop is allowed", () => {
287
- const el = document.createElement("div");
288
- const dz = new DropZone({ current: el });
289
- dz.setup();
290
- dz.onMount();
291
- const e = dragEvent("dragover");
292
- el.dispatchEvent(e);
293
- expect(e.defaultPrevented).toBe(true);
294
- });
295
-
296
- it("adds validated dropped files and stops dragging", () => {
297
- const el = document.createElement("div");
298
- const dz = new DropZone({ current: el }, { accept: "image/*", multiple: true });
299
- const view = dz.setup() as unknown as View;
300
- dz.onMount();
301
- const png = makeFile("a.png", 1, "image/png");
302
- const txt = makeFile("a.txt");
303
-
304
- el.dispatchEvent(dragEvent("dragenter"));
305
- const drop = dragEvent("drop", [png, txt]);
306
- el.dispatchEvent(drop);
307
-
308
- expect(drop.defaultPrevented).toBe(true);
309
- expect(view.dragging()).toBe(false);
310
- expect(view.files()).toEqual([png]);
311
- expect(view.errors()[0].code).toBe("type");
312
- });
313
-
314
- it("does not allow drops for dragover events without files", () => {
315
- const el = document.createElement("div");
316
- const dz = new DropZone({ current: el });
317
- dz.setup();
318
- dz.onMount();
319
- const e = dragEvent("dragover", [], ["text/plain"]);
320
- el.dispatchEvent(e);
321
- expect(e.defaultPrevented).toBe(false);
322
- });
323
-
324
- it("ignores events with no dataTransfer and drops without one", () => {
325
- const el = document.createElement("div");
326
- const dz = new DropZone({ current: el });
327
- const view = dz.setup() as unknown as View;
328
- dz.onMount();
329
- el.dispatchEvent(new Event("dragenter", { cancelable: true }));
330
- el.dispatchEvent(new Event("dragover", { cancelable: true }));
331
- expect(view.dragging()).toBe(false);
332
- el.dispatchEvent(new Event("drop", { cancelable: true }));
333
- expect(view.files()).toEqual([]);
334
- });
335
-
336
- it("dragleave without a matching dragenter keeps dragging false", () => {
337
- const el = document.createElement("div");
338
- const dz = new DropZone({ current: el });
339
- const view = dz.setup() as unknown as View;
340
- dz.onMount();
341
- el.dispatchEvent(dragEvent("dragleave"));
342
- expect(view.dragging()).toBe(false);
343
- });
344
-
345
- it("onUnmount before onMount is a no-op", () => {
346
- const dz = new DropZone({ current: document.createElement("div") });
347
- dz.setup();
348
- expect(() => { dz.onUnmount(); }).not.toThrow();
349
- });
350
-
351
- it("does nothing when the ref is empty", () => {
352
- const dz = new DropZone({ current: null });
353
- dz.setup();
354
- expect(() => { dz.onMount(); dz.onUnmount(); }).not.toThrow();
355
- });
356
-
357
- it("removes listeners on unmount", () => {
358
- const el = document.createElement("div");
359
- const dz = new DropZone({ current: el });
360
- const view = dz.setup() as unknown as View;
361
- dz.onMount();
362
- dz.onUnmount();
363
- el.dispatchEvent(dragEvent("dragenter"));
364
- expect(view.dragging()).toBe(false);
365
- });
366
- });
367
-
368
- // ── FileUpload ────────────────────────────────────────────────────────────────
369
-
370
- describe("FileUpload", () => {
371
- type View = {
372
- items: () => UploadItem[];
373
- errors: () => FileError[];
374
- uploading: () => boolean;
375
- progress: () => number;
376
- add: (f: File[]) => void;
377
- start: () => void;
378
- cancel: (id: string) => void;
379
- retry: (id: string) => void;
380
- remove: (id: string) => void;
381
- clear: () => void;
382
- };
383
-
384
- beforeEach(() => {
385
- FakeXHR.instances = [];
386
- vi.stubGlobal("XMLHttpRequest", FakeXHR);
387
- });
388
- afterEach(() => {
389
- vi.unstubAllGlobals();
390
- });
391
-
392
- it("uploads on add with the configured request", () => {
393
- const up = new FileUpload({
394
- url: "/api/upload",
395
- method: "PUT",
396
- headers: { Authorization: "Bearer x" },
397
- fieldName: "attachment",
398
- data: { folder: "docs" },
399
- withCredentials: true,
400
- });
401
- const view = up.setup() as unknown as View;
402
- const file = makeFile("a.txt");
403
- view.add([file]);
404
-
405
- const xhr = FakeXHR.instances[0];
406
- expect(xhr.method).toBe("PUT");
407
- expect(xhr.url).toBe("/api/upload");
408
- expect(xhr.headers).toEqual({ Authorization: "Bearer x" });
409
- expect(xhr.withCredentials).toBe(true);
410
- expect(xhr.body?.get("attachment")).toBe(file);
411
- expect(xhr.body?.get("folder")).toBe("docs");
412
- expect(view.items()[0].status).toBe("uploading");
413
- expect(view.uploading()).toBe(true);
414
- });
415
-
416
- it("resolves url, headers and data from functions", () => {
417
- const up = new FileUpload({
418
- url: (f) => `/up/${f.name}`,
419
- headers: () => ({ "X-Token": "t" }),
420
- data: (f) => ({ name: f.name }),
421
- });
422
- const view = up.setup() as unknown as View;
423
- view.add([makeFile("a.txt")]);
424
- const xhr = FakeXHR.instances[0];
425
- expect(xhr.url).toBe("/up/a.txt");
426
- expect(xhr.headers).toEqual({ "X-Token": "t" });
427
- expect(xhr.body?.get("name")).toBe("a.txt");
428
- expect(xhr.body?.get("file")).toBeInstanceOf(File);
429
- });
430
-
431
- it("reports progress per item and overall", () => {
432
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
433
- view.add([makeFile("a"), makeFile("b")]);
434
- FakeXHR.instances[0].upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 });
435
- FakeXHR.instances[1].upload.onprogress?.({ lengthComputable: true, loaded: 100, total: 100 });
436
- expect(view.items()[0].progress).toBe(50);
437
- expect(view.items()[1].progress).toBe(100);
438
- expect(view.progress()).toBe(75);
439
- });
440
-
441
- it("ignores non-computable progress events", () => {
442
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
443
- view.add([makeFile("a")]);
444
- FakeXHR.instances[0].upload.onprogress?.({ lengthComputable: false, loaded: 5, total: 0 });
445
- expect(view.items()[0].progress).toBe(0);
446
- });
447
-
448
- it("marks success, parses JSON and calls onSuccess", () => {
449
- const onSuccess = vi.fn();
450
- const view = new FileUpload({ url: "/u", onSuccess }).setup() as unknown as View;
451
- view.add([makeFile("a")]);
452
- FakeXHR.instances[0].respond(200, '{"id":7}');
453
- const item = view.items()[0];
454
- expect(item.status).toBe("success");
455
- expect(item.progress).toBe(100);
456
- expect(item.response).toEqual({ id: 7 });
457
- expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ status: "success" }));
458
- expect(view.uploading()).toBe(false);
459
- });
460
-
461
- it("keeps raw text for non-JSON responses", () => {
462
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
463
- view.add([makeFile("a")]);
464
- FakeXHR.instances[0].respond(201, "ok");
465
- expect(view.items()[0].response).toBe("ok");
466
- });
467
-
468
- it("marks HTTP failures and calls onError", () => {
469
- const onError = vi.fn();
470
- const view = new FileUpload({ url: "/u", onError }).setup() as unknown as View;
471
- view.add([makeFile("a")]);
472
- FakeXHR.instances[0].respond(500);
473
- expect(view.items()[0]).toMatchObject({ status: "error", error: "HTTP 500" });
474
- expect(onError).toHaveBeenCalledTimes(1);
475
- });
476
-
477
- it("marks network errors", () => {
478
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
479
- view.add([makeFile("a")]);
480
- FakeXHR.instances[0].onerror?.();
481
- expect(view.items()[0]).toMatchObject({ status: "error", error: "Network error" });
482
- });
483
-
484
- it("respects concurrency and starts queued files as slots free up", () => {
485
- const view = new FileUpload({ url: "/u", concurrency: 2 }).setup() as unknown as View;
486
- view.add([makeFile("a"), makeFile("b"), makeFile("c")]);
487
- expect(FakeXHR.instances).toHaveLength(2);
488
- expect(view.items().map((i) => i.status)).toEqual(["uploading", "uploading", "pending"]);
489
- FakeXHR.instances[0].respond(200);
490
- expect(FakeXHR.instances).toHaveLength(3);
491
- expect(view.items()[2].status).toBe("uploading");
492
- });
493
-
494
- it("does not upload until start() when autoStart is false", () => {
495
- const view = new FileUpload({ url: "/u", autoStart: false }).setup() as unknown as View;
496
- view.add([makeFile("a")]);
497
- expect(FakeXHR.instances).toHaveLength(0);
498
- expect(view.items()[0].status).toBe("pending");
499
- view.start();
500
- expect(FakeXHR.instances).toHaveLength(1);
501
- });
502
-
503
- it("validates files and exposes the rejections", () => {
504
- const view = new FileUpload({ url: "/u", maxSize: 5 }).setup() as unknown as View;
505
- view.add([makeFile("big", 100)]);
506
- expect(view.items()).toEqual([]);
507
- expect(view.errors()[0].code).toBe("max-size");
508
- expect(FakeXHR.instances).toHaveLength(0);
509
- });
510
-
511
- it("counts existing items against maxFiles", () => {
512
- const view = new FileUpload({ url: "/u", maxFiles: 1 }).setup() as unknown as View;
513
- view.add([makeFile("a")]);
514
- view.add([makeFile("b")]);
515
- expect(view.items()).toHaveLength(1);
516
- expect(view.errors()[0].code).toBe("max-files");
517
- });
518
-
519
- it("cancel aborts an in-flight upload", () => {
520
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
521
- view.add([makeFile("a")]);
522
- view.cancel(view.items()[0].id);
523
- expect(FakeXHR.instances[0].aborted).toBe(true);
524
- expect(view.items()[0].status).toBe("aborted");
525
- });
526
-
527
- it("cancel marks a queued upload as aborted so it never starts", () => {
528
- const view = new FileUpload({ url: "/u", autoStart: false }).setup() as unknown as View;
529
- view.add([makeFile("a")]);
530
- view.cancel(view.items()[0].id);
531
- view.start();
532
- expect(view.items()[0].status).toBe("aborted");
533
- expect(FakeXHR.instances).toHaveLength(0);
534
- });
535
-
536
- it("cancel ignores unknown ids and finished items", () => {
537
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
538
- view.add([makeFile("a")]);
539
- FakeXHR.instances[0].respond(200);
540
- view.cancel(view.items()[0].id);
541
- view.cancel("missing");
542
- expect(view.items()[0].status).toBe("success");
543
- });
544
-
545
- it("retry ignores unknown ids", () => {
546
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
547
- view.retry("missing");
548
- expect(FakeXHR.instances).toHaveLength(0);
549
- });
550
-
551
- it("add ignores empty input", () => {
552
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
553
- view.add([]);
554
- expect(view.items()).toEqual([]);
555
- expect(view.errors()).toEqual([]);
556
- });
557
-
558
- it("retry re-uploads failed and aborted items only", () => {
559
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
560
- view.add([makeFile("a")]);
561
- const id = view.items()[0].id;
562
- view.retry(id);
563
- expect(FakeXHR.instances).toHaveLength(1);
564
- FakeXHR.instances[0].respond(500);
565
- view.retry(id);
566
- expect(FakeXHR.instances).toHaveLength(2);
567
- expect(view.items()[0]).toMatchObject({ status: "uploading", progress: 0, error: undefined });
568
- });
569
-
570
- it("remove aborts and drops the item; clear aborts everything", () => {
571
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
572
- view.add([makeFile("a"), makeFile("b")]);
573
- view.remove(view.items()[0].id);
574
- expect(FakeXHR.instances[0].aborted).toBe(true);
575
- expect(view.items()).toHaveLength(1);
576
- view.clear();
577
- expect(FakeXHR.instances[1].aborted).toBe(true);
578
- expect(view.items()).toEqual([]);
579
- expect(view.errors()).toEqual([]);
580
- });
581
-
582
- it("progress is 0 with no items", () => {
583
- const view = new FileUpload({ url: "/u" }).setup() as unknown as View;
584
- expect(view.progress()).toBe(0);
585
- });
586
-
587
- it("onUnmount aborts in-flight requests and stops queueing", () => {
588
- const up = new FileUpload({ url: "/u", concurrency: 1 });
589
- const view = up.setup() as unknown as View;
590
- view.add([makeFile("a"), makeFile("b")]);
591
- up.onUnmount();
592
- expect(FakeXHR.instances[0].aborted).toBe(true);
593
- expect(FakeXHR.instances).toHaveLength(1);
594
- });
595
- });
596
-
597
- // ── FilePreview ───────────────────────────────────────────────────────────────
598
-
599
- describe("FilePreview", () => {
600
- beforeEach(() => {
601
- let n = 0;
602
- URL.createObjectURL = vi.fn(() => `blob:preview-${++n}`);
603
- URL.revokeObjectURL = vi.fn();
604
- });
605
-
606
- it("is null without a file", () => {
607
- const { url } = new FilePreview(() => null).setup() as { url: () => string | null };
608
- expect(url()).toBeNull();
609
- expect(URL.createObjectURL).not.toHaveBeenCalled();
610
- });
611
-
612
- it("creates one object URL per file and reuses it", () => {
613
- const file = signal<File | null>(makeFile("a.png"));
614
- const { url } = new FilePreview(file).setup() as { url: () => string | null };
615
- expect(url()).toBe("blob:preview-1");
616
- expect(url()).toBe("blob:preview-1");
617
- expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
618
- });
619
-
620
- it("revokes the previous URL when the file changes", () => {
621
- const file = signal<File | null>(makeFile("a.png"));
622
- const { url } = new FilePreview(file).setup() as { url: () => string | null };
623
- url();
624
- file.set(makeFile("b.png"));
625
- expect(url()).toBe("blob:preview-2");
626
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:preview-1");
627
- file.set(null);
628
- expect(url()).toBeNull();
629
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:preview-2");
630
- });
631
-
632
- it("revokes the URL on unmount", () => {
633
- const preview = new FilePreview(() => makeFile("a.png"));
634
- const { url } = preview.setup() as { url: () => string | null };
635
- url();
636
- preview.onUnmount();
637
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:preview-1");
638
- });
639
- });