@vaadin/upload 25.0.3 → 25.1.0-alpha2

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.
@@ -0,0 +1,720 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2000 - 2026 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+
7
+ /**
8
+ * A pure JavaScript class that manages file upload state and XHR requests.
9
+ * It has no knowledge of UI components - components should listen to events and
10
+ * call methods to interact with the manager.
11
+ *
12
+ * @example
13
+ * ```javascript
14
+ * import { UploadManager } from '@vaadin/upload';
15
+ *
16
+ * const manager = new UploadManager({
17
+ * target: '/api/upload',
18
+ * maxFiles: 5
19
+ * });
20
+ *
21
+ * // Listen to state changes
22
+ * manager.addEventListener('files-changed', (e) => {
23
+ * myFileList.items = e.detail.value;
24
+ * });
25
+ *
26
+ * manager.addEventListener('max-files-reached-changed', (e) => {
27
+ * myAddButton.disabled = e.detail.value;
28
+ * });
29
+ *
30
+ * manager.addEventListener('upload-success', (e) => {
31
+ * console.log('File uploaded:', e.detail.file.name);
32
+ * });
33
+ *
34
+ * // Add files (e.g., from a file input or drop event)
35
+ * fileInput.addEventListener('change', (e) => {
36
+ * manager.addFiles(e.target.files);
37
+ * });
38
+ * ```
39
+ *
40
+ * @fires {CustomEvent} file-reject - Fired when a file cannot be added due to constraints
41
+ * @fires {CustomEvent} file-remove - Fired when a file is removed from the list
42
+ * @fires {CustomEvent} upload-before - Fired before the XHR is opened (can modify uploadTarget)
43
+ * @fires {CustomEvent} upload-request - Fired after the XHR is opened (can modify FormData/headers)
44
+ * @fires {CustomEvent} upload-start - Fired when the upload starts
45
+ * @fires {CustomEvent} upload-progress - Fired during upload progress
46
+ * @fires {CustomEvent} upload-response - Fired when response is received
47
+ * @fires {CustomEvent} upload-success - Fired on successful upload
48
+ * @fires {CustomEvent} upload-error - Fired on upload error
49
+ * @fires {CustomEvent} upload-retry - Fired when retry is requested
50
+ * @fires {CustomEvent} upload-abort - Fired when abort is requested
51
+ * @fires {CustomEvent} files-changed - Fired when the files array changes
52
+ * @fires {CustomEvent} max-files-reached-changed - Fired when maxFilesReached changes
53
+ */
54
+ export class UploadManager extends EventTarget {
55
+ /** @type {Array<UploadFile>} */
56
+ #files = [];
57
+
58
+ /** @type {boolean} */
59
+ #maxFilesReached = false;
60
+
61
+ /** @type {Array<UploadFile>} */
62
+ #uploadQueue = [];
63
+
64
+ /** @type {number} */
65
+ #activeUploads = 0;
66
+
67
+ /** @type {string} */
68
+ #method = 'POST';
69
+
70
+ /** @type {number} */
71
+ #maxFiles = Infinity;
72
+
73
+ /** @type {number} */
74
+ #maxConcurrentUploads = 3;
75
+
76
+ /** @type {Record<string, string>} */
77
+ #headers = {};
78
+
79
+ /**
80
+ * Create an UploadManager instance.
81
+ * @param {Object} options - Configuration options
82
+ * @param {string} [options.target=''] - The server URL. The default value is an empty string, which means that _window.location_ will be used.
83
+ * @param {string} [options.method='POST'] - HTTP Method used to send the files. Only POST and PUT are allowed.
84
+ * @param {Object} [options.headers={}] - Key-Value map to send to the server.
85
+ * @param {number} [options.timeout=0] - Max time in milliseconds for the entire upload process, if exceeded the request will be aborted. Zero means that there is no timeout.
86
+ * @param {number} [options.maxFiles=Infinity] - Limit of files to upload, by default it is unlimited. If the value is set to one, native file browser will prevent selecting multiple files.
87
+ * @param {number} [options.maxFileSize=Infinity] - Specifies the maximum file size in bytes allowed to upload. Notice that it is a client-side constraint, which will be checked before sending the request. Obviously you need to do the same validation in the server-side and be sure that they are aligned.
88
+ * @param {string} [options.accept=''] - Specifies the types of files that the server accepts. Syntax: a comma-separated list of MIME type patterns (wildcards are allowed) or file extensions. Notice that MIME types are widely supported, while file extensions are only implemented in certain browsers, so avoid using it. Example: accept="video/*,image/tiff" or accept=".pdf,audio/mp3"
89
+ * @param {boolean} [options.noAuto=false] - Prevents upload(s) from immediately uploading upon adding file(s). When set, you must manually trigger uploads using the `uploadFiles` method.
90
+ * @param {boolean} [options.withCredentials=false] - Set the withCredentials flag on the request.
91
+ * @param {string} [options.uploadFormat='raw'] - Specifies the upload format to use when sending files to the server. 'raw': Send file as raw binary data with the file's MIME type as Content-Type (default). 'multipart': Send file using multipart/form-data encoding.
92
+ * @param {number} [options.maxConcurrentUploads=3] - Specifies the maximum number of files that can be uploaded simultaneously. This helps prevent browser performance degradation and XHR limitations when uploading large numbers of files. Files exceeding this limit will be queued and uploaded as active uploads complete.
93
+ * @param {string} [options.formDataName='file'] - Specifies the 'name' property at Content-Disposition for multipart uploads. This property is ignored when uploadFormat is 'raw'.
94
+ */
95
+ constructor(options = {}) {
96
+ super();
97
+
98
+ // Configuration properties - use setters for validation
99
+ this.target = options.target || '';
100
+ this.method = options.method || 'POST';
101
+ this.headers = options.headers || {};
102
+ this.timeout = options.timeout || 0;
103
+ this.maxFiles = options.maxFiles === undefined ? Infinity : options.maxFiles;
104
+ this.maxFileSize = options.maxFileSize === undefined ? Infinity : options.maxFileSize;
105
+ this.accept = options.accept || '';
106
+ this.noAuto = options.noAuto === undefined ? false : options.noAuto;
107
+ this.withCredentials = options.withCredentials === undefined ? false : options.withCredentials;
108
+ this.uploadFormat = options.uploadFormat || 'raw';
109
+ this.maxConcurrentUploads = options.maxConcurrentUploads === undefined ? 3 : options.maxConcurrentUploads;
110
+ this.formDataName = options.formDataName || 'file';
111
+ }
112
+
113
+ /**
114
+ * HTTP Method used to send the files. Only POST and PUT are allowed.
115
+ * @type {string}
116
+ */
117
+ get method() {
118
+ return this.#method;
119
+ }
120
+
121
+ set method(value) {
122
+ if (value !== 'POST' && value !== 'PUT') {
123
+ throw new Error(`Invalid method "${value}". Only POST and PUT are allowed.`);
124
+ }
125
+ this.#method = value;
126
+ }
127
+
128
+ /**
129
+ * Limit of files to upload, by default it is unlimited.
130
+ * @type {number}
131
+ */
132
+ get maxFiles() {
133
+ return this.#maxFiles;
134
+ }
135
+
136
+ set maxFiles(value) {
137
+ if (value < 0) {
138
+ throw new Error(`Invalid maxFiles "${value}". Value must be non-negative.`);
139
+ }
140
+ this.#maxFiles = value;
141
+ this.#updateMaxFilesReached();
142
+ }
143
+
144
+ /**
145
+ * Maximum number of files that can be uploaded simultaneously.
146
+ * @type {number}
147
+ */
148
+ get maxConcurrentUploads() {
149
+ return this.#maxConcurrentUploads;
150
+ }
151
+
152
+ set maxConcurrentUploads(value) {
153
+ if (value <= 0) {
154
+ throw new Error(`Invalid maxConcurrentUploads "${value}". Value must be positive.`);
155
+ }
156
+ this.#maxConcurrentUploads = value;
157
+ }
158
+
159
+ /**
160
+ * Key-Value map to send to the server.
161
+ * @type {Record<string, string>}
162
+ */
163
+ get headers() {
164
+ return this.#headers;
165
+ }
166
+
167
+ set headers(value) {
168
+ // Create a shallow copy to prevent external mutation
169
+ this.#headers = { ...value };
170
+ }
171
+
172
+ /**
173
+ * The array of files being processed, or already uploaded.
174
+ *
175
+ * Each element is a [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File)
176
+ * object with a number of extra properties to track the upload process:
177
+ * - `uploadTarget`: The target URL used to upload this file.
178
+ * - `elapsed`: Elapsed time since the upload started.
179
+ * - `remaining`: Number of seconds remaining for the upload to finish.
180
+ * - `progress`: Percentage of the file already uploaded.
181
+ * - `speed`: Upload speed in kB/s.
182
+ * - `size`: File size in bytes.
183
+ * - `total`: The total size of the data being transmitted or processed
184
+ * - `loaded`: Bytes transferred so far.
185
+ * - `status`: Status of the upload process.
186
+ * - `errorKey`: Error key in case the upload failed.
187
+ * - `abort`: True if the file was canceled by the user.
188
+ * - `complete`: True when the file was transferred to the server.
189
+ * - `uploading`: True while transferring data to the server.
190
+ *
191
+ * **Note:** The getter returns a shallow copy of the internal array to prevent
192
+ * external mutation. Modifying the returned array will not affect the manager's state.
193
+ *
194
+ * **Note:** The setter validates files against maxFiles, maxFileSize, and accept constraints.
195
+ * Files that fail validation will be rejected with a 'file-reject' event.
196
+ * @type {Array<UploadFile>}
197
+ */
198
+ get files() {
199
+ return [...this.#files];
200
+ }
201
+
202
+ set files(value) {
203
+ const validFiles = [];
204
+
205
+ for (const file of value) {
206
+ // Skip validation for files already in the list
207
+ if (this.#files.includes(file)) {
208
+ validFiles.push(file);
209
+ continue;
210
+ }
211
+
212
+ const error = this.#validateFile(file, validFiles.length);
213
+ if (error) {
214
+ this.dispatchEvent(
215
+ new CustomEvent('file-reject', {
216
+ detail: { file, error },
217
+ }),
218
+ );
219
+ continue;
220
+ }
221
+
222
+ validFiles.push(file);
223
+ }
224
+
225
+ this.#setFiles(validFiles);
226
+ }
227
+
228
+ // Internal setter - bypasses validation for internal use only
229
+ #setFiles(value) {
230
+ this.#files = value;
231
+ this.#updateMaxFilesReached();
232
+ this.#notifyFilesChanged();
233
+ }
234
+
235
+ /**
236
+ * Specifies if the maximum number of files have been uploaded.
237
+ * @type {boolean}
238
+ * @readonly
239
+ */
240
+ get maxFilesReached() {
241
+ return this.#maxFilesReached;
242
+ }
243
+
244
+ /**
245
+ * Add files to the upload list.
246
+ * @param {FileList|File[]} files - Files to add
247
+ */
248
+ addFiles(files) {
249
+ Array.from(files).forEach((file) => this.#addFile(file));
250
+ }
251
+
252
+ /**
253
+ * Triggers the upload of any files that are not completed.
254
+ *
255
+ * @param {UploadFile|UploadFile[]} [files] - Files being uploaded. Defaults to all outstanding files.
256
+ */
257
+ uploadFiles(files = this.#files) {
258
+ if (files && !Array.isArray(files)) {
259
+ files = [files];
260
+ }
261
+ // Only upload files that are managed by this instance and not already complete
262
+ files.filter((file) => this.#files.includes(file) && !file.complete).forEach((file) => this.#queueFileUpload(file));
263
+ }
264
+
265
+ /**
266
+ * Retry a failed upload.
267
+ * @param {UploadFile} file - The file to retry
268
+ */
269
+ retryUpload(file) {
270
+ this.#retryFileUpload(file);
271
+ }
272
+
273
+ /**
274
+ * Abort an upload.
275
+ * @param {UploadFile} file - The file to abort
276
+ */
277
+ abortUpload(file) {
278
+ this.#abortFileUpload(file);
279
+ }
280
+
281
+ /**
282
+ * Remove a file from the list.
283
+ * @param {UploadFile} file - The file to remove
284
+ */
285
+ removeFile(file) {
286
+ this.#removeFile(file);
287
+ }
288
+
289
+ // ============ Private methods ============
290
+
291
+ get #acceptRegexp() {
292
+ if (!this.accept) {
293
+ return null;
294
+ }
295
+ const processedTokens = this.accept.split(',').map((token) => {
296
+ let processedToken = token.trim();
297
+ processedToken = processedToken.replaceAll(/[+.]/gu, String.raw`\$&`);
298
+ if (processedToken.startsWith(String.raw`\.`)) {
299
+ processedToken = `.*${processedToken}$`;
300
+ }
301
+ return processedToken.replaceAll('/*', '/.*');
302
+ });
303
+ return new RegExp(`^(${processedTokens.join('|')})$`, 'iu');
304
+ }
305
+
306
+ #updateMaxFilesReached() {
307
+ const reached = this.maxFiles >= 0 && this.#files.length >= this.maxFiles;
308
+ if (reached !== this.#maxFilesReached) {
309
+ this.#maxFilesReached = reached;
310
+ this.dispatchEvent(
311
+ new CustomEvent('max-files-reached-changed', {
312
+ detail: { value: reached },
313
+ }),
314
+ );
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Validates a file against constraints.
320
+ * @param {File} file - The file to validate
321
+ * @param {number} currentCount - Current number of files (for maxFiles check)
322
+ * @returns {string|null} Error code if invalid, null if valid
323
+ */
324
+ #validateFile(file, currentCount) {
325
+ if (currentCount >= this.maxFiles) {
326
+ return 'tooManyFiles';
327
+ }
328
+ if (this.maxFileSize >= 0 && file.size > this.maxFileSize) {
329
+ return 'fileIsTooBig';
330
+ }
331
+ const re = this.#acceptRegexp;
332
+ if (re && !(re.test(file.type) || re.test(file.name))) {
333
+ return 'incorrectFileType';
334
+ }
335
+ return null;
336
+ }
337
+
338
+ #addFile(file) {
339
+ const error = this.#validateFile(file, this.#files.length);
340
+ if (error) {
341
+ this.dispatchEvent(
342
+ new CustomEvent('file-reject', {
343
+ detail: { file, error },
344
+ }),
345
+ );
346
+ return;
347
+ }
348
+
349
+ file.loaded = 0;
350
+ file.held = true;
351
+ file.formDataName = this.formDataName;
352
+ this.#setFiles([file, ...this.#files]);
353
+
354
+ if (!this.noAuto) {
355
+ this.#queueFileUpload(file);
356
+ }
357
+ }
358
+
359
+ #removeFile(file) {
360
+ this.#uploadQueue = this.#uploadQueue.filter((f) => f !== file);
361
+
362
+ // If the file is actively uploading (not held) and not already aborted, abort the XHR
363
+ if (file.uploading && !file.held && !file.abort && file.xhr) {
364
+ file.abort = true;
365
+ file.xhr.abort();
366
+ }
367
+
368
+ const fileIndex = this.#files.indexOf(file);
369
+ if (fileIndex >= 0) {
370
+ this.#setFiles(this.#files.filter((f) => f !== file));
371
+
372
+ this.dispatchEvent(
373
+ new CustomEvent('file-remove', {
374
+ detail: { file, fileIndex },
375
+ }),
376
+ );
377
+ }
378
+ }
379
+
380
+ #queueFileUpload(file) {
381
+ if (file.uploading) {
382
+ return;
383
+ }
384
+
385
+ // Prevent duplicate entries in queue
386
+ if (this.#uploadQueue.includes(file)) {
387
+ return;
388
+ }
389
+
390
+ file.loaded = 0;
391
+ file.progress = 0;
392
+ file.held = true;
393
+ file.uploading = file.indeterminate = true;
394
+ file.complete = file.abort = file.errorKey = false;
395
+ file.stalled = false;
396
+ this.#notifyFilesChanged();
397
+
398
+ this.#uploadQueue.push(file);
399
+ this.#processUploadQueue();
400
+ }
401
+
402
+ #processUploadQueue() {
403
+ while (this.#uploadQueue.length > 0 && this.#activeUploads < this.maxConcurrentUploads) {
404
+ const nextFile = this.#uploadQueue.shift();
405
+ if (nextFile) {
406
+ this.#uploadFile(nextFile);
407
+ }
408
+ }
409
+ }
410
+
411
+ #uploadFile(file) {
412
+ this.#activeUploads += 1;
413
+
414
+ const ini = Date.now();
415
+ const xhr = (file.xhr = this._createXhr());
416
+
417
+ let stalledId;
418
+
419
+ xhr.upload.onprogress = (e) => {
420
+ clearTimeout(stalledId);
421
+
422
+ const elapsed = (Date.now() - ini) / 1000;
423
+ const loaded = e.loaded;
424
+ const total = e.total;
425
+ // Clamp to [0, 100] range
426
+ const rawProgress = total > 0 ? Math.trunc((loaded / total) * 100) : 100;
427
+ const progress = Math.max(0, Math.min(100, rawProgress));
428
+ file.loaded = loaded;
429
+ file.progress = progress;
430
+ file.indeterminate = total > 0 ? loaded <= 0 || loaded >= total : false;
431
+
432
+ // Reset stalled flag when progress resumes
433
+ if (file.stalled) {
434
+ file.stalled = false;
435
+ }
436
+
437
+ if (file.errorKey) {
438
+ file.indeterminate = file.status = undefined;
439
+ } else if (!file.abort) {
440
+ if (progress < 100) {
441
+ this.#setStatus(file, total, loaded, elapsed);
442
+ stalledId = setTimeout(() => {
443
+ // Only set stalled if file is still uploading and not aborted
444
+ if (file.uploading && !file.abort) {
445
+ file.stalled = true;
446
+ this.#notifyFilesChanged();
447
+ }
448
+ }, 2000);
449
+ }
450
+ }
451
+
452
+ this.#notifyFilesChanged();
453
+ this.dispatchEvent(new CustomEvent('upload-progress', { detail: { file, xhr } }));
454
+ };
455
+
456
+ xhr.onabort = () => {
457
+ clearTimeout(stalledId);
458
+ this.#activeUploads -= 1;
459
+ this.#cleanupXhr(xhr);
460
+ this.#processUploadQueue();
461
+ };
462
+
463
+ xhr.ontimeout = () => {
464
+ clearTimeout(stalledId);
465
+ file.indeterminate = file.uploading = false;
466
+ file.errorKey = 'timeout';
467
+ file.status = '';
468
+
469
+ this.#activeUploads -= 1;
470
+ this.#processUploadQueue();
471
+ this.#cleanupXhr(xhr);
472
+
473
+ this.dispatchEvent(new CustomEvent('upload-error', { detail: { file, xhr } }));
474
+ this.#notifyFilesChanged();
475
+ };
476
+
477
+ xhr.onreadystatechange = () => {
478
+ if (xhr.readyState === 4) {
479
+ clearTimeout(stalledId);
480
+ file.indeterminate = file.uploading = false;
481
+
482
+ this.#activeUploads -= 1;
483
+ this.#processUploadQueue();
484
+ this.#cleanupXhr(xhr);
485
+
486
+ // Return early if already handled (abort or timeout)
487
+ if (file.abort || file.errorKey) {
488
+ return;
489
+ }
490
+ file.status = '';
491
+
492
+ const evt = this.dispatchEvent(
493
+ new CustomEvent('upload-response', {
494
+ detail: { file, xhr },
495
+ cancelable: true,
496
+ }),
497
+ );
498
+
499
+ if (!evt) {
500
+ return;
501
+ }
502
+ if (xhr.status === 0) {
503
+ file.errorKey = 'serverUnavailable';
504
+ } else if (xhr.status >= 500) {
505
+ file.errorKey = 'unexpectedServerError';
506
+ } else if (xhr.status >= 400) {
507
+ file.errorKey = 'forbidden';
508
+ }
509
+
510
+ file.complete = !file.errorKey;
511
+ const eventName = file.errorKey ? 'upload-error' : 'upload-success';
512
+ this.dispatchEvent(new CustomEvent(eventName, { detail: { file, xhr } }));
513
+
514
+ // Clear file.xhr reference to allow garbage collection
515
+ file.xhr = null;
516
+
517
+ this.#notifyFilesChanged();
518
+ }
519
+ };
520
+
521
+ const isRawUpload = this.uploadFormat === 'raw';
522
+
523
+ if (!file.uploadTarget) {
524
+ file.uploadTarget = this.target;
525
+ }
526
+
527
+ const evt = this.dispatchEvent(
528
+ new CustomEvent('upload-before', {
529
+ detail: { file, xhr },
530
+ cancelable: true,
531
+ }),
532
+ );
533
+ if (!evt) {
534
+ this.#holdFile(file);
535
+ return;
536
+ }
537
+
538
+ // Check if file was removed during upload-before handler
539
+ // If file.abort is true, onabort already decremented #activeUploads
540
+ if (!this.#files.includes(file)) {
541
+ if (!file.abort) {
542
+ this.#activeUploads -= 1;
543
+ }
544
+ this.#cleanupXhr(xhr);
545
+ this.#processUploadQueue();
546
+ return;
547
+ }
548
+
549
+ let requestBody;
550
+ if (isRawUpload) {
551
+ requestBody = file;
552
+ } else {
553
+ const formData = new FormData();
554
+ formData.append(file.formDataName || this.formDataName, file, file.name);
555
+ requestBody = formData;
556
+ }
557
+
558
+ xhr.open(this.method, file.uploadTarget, true);
559
+ this.#configureXhr(xhr, file, isRawUpload);
560
+
561
+ file.held = false;
562
+
563
+ xhr.upload.onloadstart = () => {
564
+ this.dispatchEvent(
565
+ new CustomEvent('upload-start', {
566
+ detail: { file, xhr },
567
+ }),
568
+ );
569
+ this.#notifyFilesChanged();
570
+ };
571
+
572
+ const eventDetail = {
573
+ file,
574
+ xhr,
575
+ uploadFormat: this.uploadFormat,
576
+ requestBody,
577
+ };
578
+
579
+ if (!isRawUpload) {
580
+ eventDetail.formData = requestBody;
581
+ }
582
+
583
+ const uploadEvt = this.dispatchEvent(
584
+ new CustomEvent('upload-request', {
585
+ detail: eventDetail,
586
+ cancelable: true,
587
+ }),
588
+ );
589
+ if (!uploadEvt) {
590
+ this.#holdFile(file);
591
+ return;
592
+ }
593
+
594
+ // Check if file was removed during upload-request handler
595
+ // If file.abort is true, onabort already decremented #activeUploads
596
+ if (!this.#files.includes(file)) {
597
+ if (!file.abort) {
598
+ this.#activeUploads -= 1;
599
+ }
600
+ this.#cleanupXhr(xhr);
601
+ this.#processUploadQueue();
602
+ return;
603
+ }
604
+
605
+ try {
606
+ xhr.send(requestBody);
607
+ } catch (e) {
608
+ this.#activeUploads -= 1;
609
+ file.uploading = false;
610
+ file.indeterminate = false;
611
+ file.errorKey = e.message || 'sendFailed';
612
+ this.#cleanupXhr(xhr);
613
+ this.#notifyFilesChanged();
614
+ this.#processUploadQueue();
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Creates an XMLHttpRequest instance. Override in tests to mock XHR behavior.
620
+ * @private
621
+ */
622
+ _createXhr() {
623
+ return new XMLHttpRequest();
624
+ }
625
+
626
+ /**
627
+ * Reset file state when upload is prevented.
628
+ */
629
+ #holdFile(file) {
630
+ this.#activeUploads -= 1;
631
+ file.uploading = false;
632
+ file.indeterminate = false;
633
+ file.held = true;
634
+ this.#notifyFilesChanged();
635
+ this.#processUploadQueue();
636
+ }
637
+
638
+ /**
639
+ * Clean up XHR handlers to prevent memory leaks
640
+ */
641
+ #cleanupXhr(xhr) {
642
+ if (xhr) {
643
+ xhr.upload.onprogress = null;
644
+ xhr.upload.onloadstart = null;
645
+ xhr.onreadystatechange = null;
646
+ xhr.onabort = null;
647
+ xhr.ontimeout = null;
648
+ }
649
+ }
650
+
651
+ #configureXhr(xhr, file, isRawUpload) {
652
+ Object.entries(this.headers).forEach(([key, value]) => {
653
+ xhr.setRequestHeader(key, value);
654
+ });
655
+
656
+ if (isRawUpload) {
657
+ xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
658
+ xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
659
+ }
660
+
661
+ if (this.timeout) {
662
+ xhr.timeout = this.timeout;
663
+ }
664
+ xhr.withCredentials = this.withCredentials;
665
+ }
666
+
667
+ #retryFileUpload(file) {
668
+ const evt = this.dispatchEvent(
669
+ new CustomEvent('upload-retry', {
670
+ detail: { file, xhr: file.xhr },
671
+ cancelable: true,
672
+ }),
673
+ );
674
+ if (evt) {
675
+ // Reset uploading flag so #queueFileUpload doesn't early-return
676
+ // This allows retrying queued files that haven't started yet
677
+ file.uploading = false;
678
+ // Remove from queue if present (for queued files being retried)
679
+ this.#uploadQueue = this.#uploadQueue.filter((f) => f !== file);
680
+ this.#queueFileUpload(file);
681
+ }
682
+ }
683
+
684
+ #abortFileUpload(file) {
685
+ const evt = this.dispatchEvent(
686
+ new CustomEvent('upload-abort', {
687
+ detail: { file, xhr: file.xhr },
688
+ cancelable: true,
689
+ }),
690
+ );
691
+ if (evt) {
692
+ file.abort = true;
693
+ if (file.xhr) {
694
+ file.xhr.abort();
695
+ }
696
+ this.#removeFile(file);
697
+ }
698
+ }
699
+
700
+ #setStatus(file, total, loaded, elapsed) {
701
+ file.elapsed = elapsed;
702
+ // Avoid division by zero - if loaded is 0, remaining is unknown
703
+ file.remaining = loaded > 0 ? Math.ceil(elapsed * (total / loaded - 1)) : 0;
704
+ // Speed should be based on bytes actually transferred, not total file size
705
+ // Avoid division by zero - if elapsed is 0, speed is 0
706
+ file.speed = elapsed > 0 ? Math.trunc(loaded / elapsed / 1024) : 0;
707
+ file.total = total;
708
+ }
709
+
710
+ #notifyFilesChanged() {
711
+ // This method is called when file properties change (progress, status, etc.)
712
+ // but not when the array structure changes. We don't track the previous state,
713
+ // so we only provide the current value.
714
+ this.dispatchEvent(
715
+ new CustomEvent('files-changed', {
716
+ detail: { value: this.#files },
717
+ }),
718
+ );
719
+ }
720
+ }