@praxisjs/composables 1.1.8 → 1.2.0
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.
- package/CHANGELOG.md +6 -0
- package/dist/__tests__/upload.test.d.ts +2 -0
- package/dist/__tests__/upload.test.d.ts.map +1 -0
- package/dist/__tests__/upload.test.js +531 -0
- package/dist/__tests__/upload.test.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/upload.d.ts +148 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +356 -0
- package/dist/upload.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/upload.test.ts +639 -0
- package/src/index.ts +15 -0
- package/src/upload.ts +450 -0
package/src/upload.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { Composable } from "@praxisjs/core";
|
|
2
|
+
import { signal, computed } from "@praxisjs/core/internal";
|
|
3
|
+
import type { Signal, Computed } from "@praxisjs/shared";
|
|
4
|
+
|
|
5
|
+
export interface FileValidationOptions {
|
|
6
|
+
/** Comma-separated string or array of `.ext`, `type/subtype` or `type/*` tokens — same syntax as `<input accept>`. */
|
|
7
|
+
accept?: string | string[];
|
|
8
|
+
/** Maximum size per file, in bytes. */
|
|
9
|
+
maxSize?: number;
|
|
10
|
+
/** Minimum size per file, in bytes. */
|
|
11
|
+
minSize?: number;
|
|
12
|
+
/** Maximum number of files held at once. */
|
|
13
|
+
maxFiles?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type FileErrorCode = "type" | "max-size" | "min-size" | "max-files";
|
|
17
|
+
|
|
18
|
+
export interface FileError {
|
|
19
|
+
file: File;
|
|
20
|
+
code: FileErrorCode;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FileSelectionOptions extends FileValidationOptions {
|
|
25
|
+
/** Defaults to `true` when `maxFiles > 1`, otherwise `false` (a new selection replaces the previous one). */
|
|
26
|
+
multiple?: boolean;
|
|
27
|
+
/** Called with the accepted files of every selection. */
|
|
28
|
+
onFiles?: (files: File[]) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type UploadStatus = "pending" | "uploading" | "success" | "error" | "aborted";
|
|
32
|
+
|
|
33
|
+
export interface UploadItem {
|
|
34
|
+
id: string;
|
|
35
|
+
file: File;
|
|
36
|
+
status: UploadStatus;
|
|
37
|
+
/** 0–100 */
|
|
38
|
+
progress: number;
|
|
39
|
+
response?: unknown;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FileUploadOptions extends FileValidationOptions {
|
|
44
|
+
url: string | ((file: File) => string);
|
|
45
|
+
method?: string;
|
|
46
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
47
|
+
/** Form field that carries the file. Defaults to `"file"`. */
|
|
48
|
+
fieldName?: string;
|
|
49
|
+
/** Extra form fields sent alongside the file. */
|
|
50
|
+
data?: Record<string, string> | ((file: File) => Record<string, string>);
|
|
51
|
+
withCredentials?: boolean;
|
|
52
|
+
/** Maximum simultaneous uploads. Defaults to `3`. */
|
|
53
|
+
concurrency?: number;
|
|
54
|
+
/** Start uploading as soon as files are added. Defaults to `true`. */
|
|
55
|
+
autoStart?: boolean;
|
|
56
|
+
onSuccess?: (item: UploadItem) => void;
|
|
57
|
+
onError?: (item: UploadItem) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatFileSize(bytes: number, decimals = 1): string {
|
|
61
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
62
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
63
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
64
|
+
const value = bytes / 1024 ** i;
|
|
65
|
+
return `${i === 0 ? String(value) : value.toFixed(decimals)} ${units[i]}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeAccept(accept?: string | string[]): string[] {
|
|
69
|
+
const list = typeof accept === "string" ? accept.split(",") : (accept ?? []);
|
|
70
|
+
return list.map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function matchesAccept(file: File, tokens: string[]): boolean {
|
|
74
|
+
const name = file.name.toLowerCase();
|
|
75
|
+
const type = file.type.toLowerCase();
|
|
76
|
+
return tokens.some((token) => {
|
|
77
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
78
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
79
|
+
return type === token;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toFileArray(input: FileList | File[] | null | undefined): File[] {
|
|
84
|
+
return input ? Array.from(input) : [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateFiles(
|
|
88
|
+
files: File[],
|
|
89
|
+
currentCount: number,
|
|
90
|
+
options: FileValidationOptions,
|
|
91
|
+
): { accepted: File[]; rejected: FileError[] } {
|
|
92
|
+
const tokens = normalizeAccept(options.accept);
|
|
93
|
+
const accepted: File[] = [];
|
|
94
|
+
const rejected: FileError[] = [];
|
|
95
|
+
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
if (tokens.length > 0 && !matchesAccept(file, tokens)) {
|
|
98
|
+
rejected.push({ file, code: "type", message: `${file.name}: file type is not allowed` });
|
|
99
|
+
} else if (options.maxSize !== undefined && file.size > options.maxSize) {
|
|
100
|
+
rejected.push({
|
|
101
|
+
file,
|
|
102
|
+
code: "max-size",
|
|
103
|
+
message: `${file.name}: exceeds the ${formatFileSize(options.maxSize)} limit`,
|
|
104
|
+
});
|
|
105
|
+
} else if (options.minSize !== undefined && file.size < options.minSize) {
|
|
106
|
+
rejected.push({
|
|
107
|
+
file,
|
|
108
|
+
code: "min-size",
|
|
109
|
+
message: `${file.name}: is smaller than ${formatFileSize(options.minSize)}`,
|
|
110
|
+
});
|
|
111
|
+
} else if (options.maxFiles !== undefined && currentCount + accepted.length >= options.maxFiles) {
|
|
112
|
+
rejected.push({
|
|
113
|
+
file,
|
|
114
|
+
code: "max-files",
|
|
115
|
+
message: `${file.name}: at most ${String(options.maxFiles)} file(s) allowed`,
|
|
116
|
+
});
|
|
117
|
+
} else {
|
|
118
|
+
accepted.push(file);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { accepted, rejected };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function openFilePicker(accept: string[], multiple: boolean, onPick: (files: File[]) => void): void {
|
|
126
|
+
const input = document.createElement("input");
|
|
127
|
+
input.type = "file";
|
|
128
|
+
input.multiple = multiple;
|
|
129
|
+
if (accept.length > 0) input.accept = accept.join(",");
|
|
130
|
+
input.addEventListener("change", () => { onPick(toFileArray(input.files)); });
|
|
131
|
+
input.click();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createSelection(options: FileSelectionOptions) {
|
|
135
|
+
const multiple = options.multiple ?? (options.maxFiles ?? 1) > 1;
|
|
136
|
+
const validation: FileValidationOptions = {
|
|
137
|
+
...options,
|
|
138
|
+
maxFiles: multiple ? options.maxFiles : 1,
|
|
139
|
+
};
|
|
140
|
+
const files = signal<File[]>([]);
|
|
141
|
+
const errors = signal<FileError[]>([]);
|
|
142
|
+
|
|
143
|
+
const add = (incoming: FileList | File[] | null | undefined): void => {
|
|
144
|
+
const list = toFileArray(incoming);
|
|
145
|
+
if (list.length === 0) return;
|
|
146
|
+
const current = multiple ? files() : [];
|
|
147
|
+
const { accepted, rejected } = validateFiles(list, current.length, validation);
|
|
148
|
+
errors.set(rejected);
|
|
149
|
+
if (accepted.length === 0) return;
|
|
150
|
+
files.set([...current, ...accepted]);
|
|
151
|
+
options.onFiles?.(accepted);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
files,
|
|
156
|
+
errors,
|
|
157
|
+
add,
|
|
158
|
+
remove: (file: File) => { files.update((list) => list.filter((f) => f !== file)); },
|
|
159
|
+
clear: () => {
|
|
160
|
+
files.set([]);
|
|
161
|
+
errors.set([]);
|
|
162
|
+
},
|
|
163
|
+
open: () => { openFilePicker(normalizeAccept(options.accept), multiple, add); },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class FileSelection extends Composable {
|
|
168
|
+
declare files: File[];
|
|
169
|
+
declare errors: FileError[];
|
|
170
|
+
declare open: () => void;
|
|
171
|
+
declare add: (files: FileList | File[] | null | undefined) => void;
|
|
172
|
+
declare remove: (file: File) => void;
|
|
173
|
+
declare clear: () => void;
|
|
174
|
+
|
|
175
|
+
constructor(private readonly options: FileSelectionOptions = {}) {
|
|
176
|
+
super();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setup() {
|
|
180
|
+
return createSelection(this.options);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class DropZone extends Composable {
|
|
185
|
+
declare files: File[];
|
|
186
|
+
declare errors: FileError[];
|
|
187
|
+
declare dragging: boolean;
|
|
188
|
+
declare open: () => void;
|
|
189
|
+
declare add: (files: FileList | File[] | null | undefined) => void;
|
|
190
|
+
declare remove: (file: File) => void;
|
|
191
|
+
declare clear: () => void;
|
|
192
|
+
|
|
193
|
+
private _dragging!: ReturnType<typeof signal<boolean>>;
|
|
194
|
+
private _add!: (files: FileList | File[] | null | undefined) => void;
|
|
195
|
+
private _depth = 0;
|
|
196
|
+
private _el: HTMLElement | null = null;
|
|
197
|
+
|
|
198
|
+
constructor(
|
|
199
|
+
private readonly ref: { current: HTMLElement | null },
|
|
200
|
+
private readonly options: FileSelectionOptions = {},
|
|
201
|
+
) {
|
|
202
|
+
super();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setup() {
|
|
206
|
+
this._dragging = signal(false);
|
|
207
|
+
const selection = createSelection(this.options);
|
|
208
|
+
this._add = selection.add;
|
|
209
|
+
return { ...selection, dragging: this._dragging };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private readonly _onDragEnter = (e: DragEvent) => {
|
|
213
|
+
if (!e.dataTransfer?.types.includes("Files")) return;
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
this._depth++;
|
|
216
|
+
this._dragging.set(true);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
private readonly _onDragOver = (e: DragEvent) => {
|
|
220
|
+
if (!e.dataTransfer?.types.includes("Files")) return;
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
private readonly _onDragLeave = () => {
|
|
225
|
+
this._depth = Math.max(0, this._depth - 1);
|
|
226
|
+
if (this._depth === 0) this._dragging.set(false);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
private readonly _onDrop = (e: DragEvent) => {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
this._depth = 0;
|
|
232
|
+
this._dragging.set(false);
|
|
233
|
+
this._add(toFileArray(e.dataTransfer?.files));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
onMount() {
|
|
237
|
+
const el = this.ref.current;
|
|
238
|
+
if (!el) return;
|
|
239
|
+
this._el = el;
|
|
240
|
+
el.addEventListener("dragenter", this._onDragEnter);
|
|
241
|
+
el.addEventListener("dragover", this._onDragOver);
|
|
242
|
+
el.addEventListener("dragleave", this._onDragLeave);
|
|
243
|
+
el.addEventListener("drop", this._onDrop);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
onUnmount() {
|
|
247
|
+
const el = this._el;
|
|
248
|
+
if (!el) return;
|
|
249
|
+
el.removeEventListener("dragenter", this._onDragEnter);
|
|
250
|
+
el.removeEventListener("dragover", this._onDragOver);
|
|
251
|
+
el.removeEventListener("dragleave", this._onDragLeave);
|
|
252
|
+
el.removeEventListener("drop", this._onDrop);
|
|
253
|
+
this._el = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export class FileUpload extends Composable {
|
|
258
|
+
declare items: UploadItem[];
|
|
259
|
+
declare errors: FileError[];
|
|
260
|
+
declare uploading: boolean;
|
|
261
|
+
declare progress: number;
|
|
262
|
+
declare add: (files: FileList | File[] | null | undefined) => void;
|
|
263
|
+
declare start: () => void;
|
|
264
|
+
declare cancel: (id: string) => void;
|
|
265
|
+
declare retry: (id: string) => void;
|
|
266
|
+
declare remove: (id: string) => void;
|
|
267
|
+
declare clear: () => void;
|
|
268
|
+
|
|
269
|
+
private readonly _requests = new Map<string, XMLHttpRequest>();
|
|
270
|
+
private _destroyed = false;
|
|
271
|
+
|
|
272
|
+
constructor(private readonly options: FileUploadOptions) {
|
|
273
|
+
super();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
setup() {
|
|
277
|
+
const opts = this.options;
|
|
278
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
279
|
+
const items = signal<UploadItem[]>([]);
|
|
280
|
+
const errors = signal<FileError[]>([]);
|
|
281
|
+
let nextId = 0;
|
|
282
|
+
|
|
283
|
+
const patch = (id: string, changes: Partial<UploadItem>) => {
|
|
284
|
+
items.update((list) => list.map((i) => (i.id === id ? { ...i, ...changes } : i)));
|
|
285
|
+
};
|
|
286
|
+
const find = (id: string) => items().find((i) => i.id === id);
|
|
287
|
+
|
|
288
|
+
const finish = (id: string, changes: Partial<UploadItem>) => {
|
|
289
|
+
this._requests.delete(id);
|
|
290
|
+
patch(id, changes);
|
|
291
|
+
const item = find(id);
|
|
292
|
+
if (item?.status === "success") opts.onSuccess?.(item);
|
|
293
|
+
else if (item?.status === "error") opts.onError?.(item);
|
|
294
|
+
pump();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const send = (item: UploadItem) => {
|
|
298
|
+
const xhr = new XMLHttpRequest();
|
|
299
|
+
this._requests.set(item.id, xhr);
|
|
300
|
+
patch(item.id, { status: "uploading", progress: 0, error: undefined });
|
|
301
|
+
|
|
302
|
+
const url = typeof opts.url === "function" ? opts.url(item.file) : opts.url;
|
|
303
|
+
xhr.open(opts.method ?? "POST", url);
|
|
304
|
+
xhr.withCredentials = opts.withCredentials ?? false;
|
|
305
|
+
const headers = typeof opts.headers === "function" ? opts.headers() : opts.headers;
|
|
306
|
+
for (const [key, value] of Object.entries(headers ?? {})) xhr.setRequestHeader(key, value);
|
|
307
|
+
|
|
308
|
+
xhr.upload.onprogress = (e) => {
|
|
309
|
+
if (e.lengthComputable) patch(item.id, { progress: Math.round((e.loaded / e.total) * 100) });
|
|
310
|
+
};
|
|
311
|
+
xhr.onload = () => {
|
|
312
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
313
|
+
let response: unknown = xhr.responseText;
|
|
314
|
+
try {
|
|
315
|
+
response = JSON.parse(xhr.responseText);
|
|
316
|
+
} catch {
|
|
317
|
+
// non-JSON body: keep the raw text
|
|
318
|
+
}
|
|
319
|
+
finish(item.id, { status: "success", progress: 100, response });
|
|
320
|
+
} else {
|
|
321
|
+
finish(item.id, { status: "error", error: `HTTP ${String(xhr.status)}` });
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
xhr.onerror = () => { finish(item.id, { status: "error", error: "Network error" }); };
|
|
325
|
+
xhr.onabort = () => { finish(item.id, { status: "aborted" }); };
|
|
326
|
+
|
|
327
|
+
const body = new FormData();
|
|
328
|
+
const extra = typeof opts.data === "function" ? opts.data(item.file) : opts.data;
|
|
329
|
+
for (const [key, value] of Object.entries(extra ?? {})) body.append(key, value);
|
|
330
|
+
body.append(opts.fieldName ?? "file", item.file);
|
|
331
|
+
xhr.send(body);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const pump = () => {
|
|
335
|
+
if (this._destroyed) return;
|
|
336
|
+
let running = items().filter((i) => i.status === "uploading").length;
|
|
337
|
+
for (const item of items()) {
|
|
338
|
+
if (running >= concurrency) break;
|
|
339
|
+
if (item.status === "pending") {
|
|
340
|
+
running++;
|
|
341
|
+
send(item);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const cancel = (id: string) => {
|
|
347
|
+
const xhr = this._requests.get(id);
|
|
348
|
+
if (xhr) xhr.abort();
|
|
349
|
+
else if (find(id)?.status === "pending") patch(id, { status: "aborted" });
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const abortAll = () => {
|
|
353
|
+
for (const xhr of [...this._requests.values()]) xhr.abort();
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const add = (incoming: FileList | File[] | null | undefined) => {
|
|
357
|
+
const list = toFileArray(incoming);
|
|
358
|
+
if (list.length === 0) return;
|
|
359
|
+
const { accepted, rejected } = validateFiles(list, items().length, opts);
|
|
360
|
+
errors.set(rejected);
|
|
361
|
+
if (accepted.length === 0) return;
|
|
362
|
+
items.update((current) => [
|
|
363
|
+
...current,
|
|
364
|
+
...accepted.map((file): UploadItem => ({
|
|
365
|
+
id: `upload-${String(++nextId)}`,
|
|
366
|
+
file,
|
|
367
|
+
status: "pending",
|
|
368
|
+
progress: 0,
|
|
369
|
+
})),
|
|
370
|
+
]);
|
|
371
|
+
if (opts.autoStart ?? true) pump();
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
items,
|
|
376
|
+
errors,
|
|
377
|
+
uploading: computed(() => items().some((i) => i.status === "uploading")),
|
|
378
|
+
progress: computed(() => {
|
|
379
|
+
const list = items();
|
|
380
|
+
if (list.length === 0) return 0;
|
|
381
|
+
return Math.round(list.reduce((sum, i) => sum + i.progress, 0) / list.length);
|
|
382
|
+
}),
|
|
383
|
+
add,
|
|
384
|
+
start: pump,
|
|
385
|
+
cancel,
|
|
386
|
+
retry: (id: string) => {
|
|
387
|
+
const status = find(id)?.status;
|
|
388
|
+
if (status !== "error" && status !== "aborted") return;
|
|
389
|
+
patch(id, { status: "pending", progress: 0, error: undefined });
|
|
390
|
+
pump();
|
|
391
|
+
},
|
|
392
|
+
remove: (id: string) => {
|
|
393
|
+
cancel(id);
|
|
394
|
+
items.update((list) => list.filter((i) => i.id !== id));
|
|
395
|
+
},
|
|
396
|
+
clear: () => {
|
|
397
|
+
abortAll();
|
|
398
|
+
items.set([]);
|
|
399
|
+
errors.set([]);
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
onUnmount() {
|
|
405
|
+
this._destroyed = true;
|
|
406
|
+
for (const xhr of [...this._requests.values()]) xhr.abort();
|
|
407
|
+
this._requests.clear();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export class FilePreview extends Composable {
|
|
412
|
+
declare url: string | null;
|
|
413
|
+
|
|
414
|
+
private _file: File | null = null;
|
|
415
|
+
private _url: string | null = null;
|
|
416
|
+
|
|
417
|
+
constructor(
|
|
418
|
+
private readonly source:
|
|
419
|
+
| Signal<File | null>
|
|
420
|
+
| Computed<File | null>
|
|
421
|
+
| (() => File | null),
|
|
422
|
+
) {
|
|
423
|
+
super();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
setup() {
|
|
427
|
+
const read = this.source;
|
|
428
|
+
|
|
429
|
+
const url = computed<string | null>(() => {
|
|
430
|
+
const file = read();
|
|
431
|
+
if (file === this._file) return this._url;
|
|
432
|
+
this._revoke();
|
|
433
|
+
this._file = file;
|
|
434
|
+
this._url = file ? URL.createObjectURL(file) : null;
|
|
435
|
+
return this._url;
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
return { url };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private _revoke() {
|
|
442
|
+
if (this._url) URL.revokeObjectURL(this._url);
|
|
443
|
+
this._url = null;
|
|
444
|
+
this._file = null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
onUnmount() {
|
|
448
|
+
this._revoke();
|
|
449
|
+
}
|
|
450
|
+
}
|