@vaadin/upload 25.2.7 → 25.3.0-alpha10

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.
@@ -6,9 +6,12 @@
6
6
  import { announce } from '@vaadin/a11y-base/src/announce.js';
7
7
  import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
8
8
  import { isTouch } from '@vaadin/component-base/src/browser-utils.js';
9
+ import { setOrRemoveAttribute } from '@vaadin/component-base/src/dom-utils.js';
9
10
  import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
10
11
  import { SlotController } from '@vaadin/component-base/src/slot-controller.js';
11
- import { getFilesFromDropEvent } from './vaadin-upload-helpers.js';
12
+ import { DEFAULT_I18N as FILE_LIST_DEFAULT_I18N } from './vaadin-upload-file-list-mixin.js';
13
+ import { getFilesFromDropEvent, translateErrorKey, updateFileStatus } from './vaadin-upload-helpers.js';
14
+ import { InternalUploadManager, parseHeaders } from './vaadin-upload-internal-manager.js';
12
15
 
13
16
  export const DEFAULT_I18N = {
14
17
  dropFiles: {
@@ -19,92 +22,12 @@ export const DEFAULT_I18N = {
19
22
  one: 'Upload File...',
20
23
  many: 'Upload Files...',
21
24
  },
22
- error: {
23
- tooManyFiles: 'Too Many Files.',
24
- fileIsTooBig: 'File is Too Big.',
25
- incorrectFileType: 'Incorrect File Type.',
26
- },
27
- uploading: {
28
- status: {
29
- connecting: 'Connecting...',
30
- stalled: 'Stalled',
31
- processing: 'Processing File...',
32
- held: 'Queued',
33
- },
34
- remainingTime: {
35
- prefix: 'remaining time: ',
36
- unknown: 'unknown remaining time',
37
- },
38
- error: {
39
- serverUnavailable: 'Upload failed, please try again later',
40
- unexpectedServerError: 'Upload failed due to server error',
41
- forbidden: 'Upload forbidden',
42
- fileTooLarge: 'File is too large',
43
- },
44
- },
45
- file: {
46
- retry: 'Retry',
47
- start: 'Start',
48
- remove: 'Remove',
49
- },
50
- units: {
51
- size: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
52
- },
25
+ error: FILE_LIST_DEFAULT_I18N.error,
26
+ uploading: FILE_LIST_DEFAULT_I18N.uploading,
27
+ file: FILE_LIST_DEFAULT_I18N.file,
28
+ units: FILE_LIST_DEFAULT_I18N.units,
53
29
  };
54
30
 
55
- class AddButtonController extends SlotController {
56
- constructor(host) {
57
- super(host, 'add-button', 'vaadin-button');
58
- }
59
-
60
- /**
61
- * Override method inherited from `SlotController`
62
- * to add listeners to default and custom node.
63
- *
64
- * @param {Node} node
65
- * @protected
66
- * @override
67
- */
68
- initNode(node) {
69
- // Needed by Flow counterpart to apply i18n to custom button
70
- if (node._isDefault) {
71
- this.defaultNode = node;
72
- }
73
-
74
- node.addEventListener('touchend', (e) => {
75
- this.host._onAddFilesTouchEnd(e);
76
- });
77
-
78
- node.addEventListener('click', (e) => {
79
- this.host._onAddFilesClick(e);
80
- });
81
-
82
- this.host._addButton = node;
83
- }
84
- }
85
-
86
- class DropLabelController extends SlotController {
87
- constructor(host) {
88
- super(host, 'drop-label', 'span');
89
- }
90
-
91
- /**
92
- * Override method inherited from `SlotController`
93
- * to add listeners to default and custom node.
94
- *
95
- * @param {Node} node
96
- * @protected
97
- * @override
98
- */
99
- initNode(node) {
100
- // Needed by Flow counterpart to apply i18n to custom label
101
- if (node._isDefault) {
102
- this.defaultNode = node;
103
- }
104
- this.host._dropLabel = node;
105
- }
106
- }
107
-
108
31
  export const UploadMixin = (superClass) =>
109
32
  class UploadMixin extends I18nMixin(superClass) {
110
33
  static get properties() {
@@ -207,6 +130,7 @@ export const UploadMixin = (superClass) =>
207
130
  notify: true,
208
131
  value: () => [],
209
132
  sync: true,
133
+ observer: '__filesChanged',
210
134
  },
211
135
 
212
136
  /**
@@ -343,32 +267,15 @@ export const UploadMixin = (superClass) =>
343
267
  _fileList: {
344
268
  type: Object,
345
269
  },
346
-
347
- /** @private */
348
- _files: {
349
- type: Array,
350
- },
351
-
352
- /** @private */
353
- _uploadQueue: {
354
- type: Array,
355
- value: () => [],
356
- },
357
-
358
- /** @private */
359
- _activeUploads: {
360
- type: Number,
361
- value: 0,
362
- },
363
270
  };
364
271
  }
365
272
 
366
273
  static get observers() {
367
274
  return [
275
+ '__updateMaxFilesReached(maxFiles, files)',
368
276
  '__updateAddButton(_addButton, maxFiles, __effectiveI18n, maxFilesReached, disabled)',
369
277
  '__updateDropLabel(_dropLabel, maxFiles, __effectiveI18n)',
370
278
  '__updateFileList(_fileList, files, __effectiveI18n, disabled, _theme)',
371
- '__updateMaxFilesReached(maxFiles, files)',
372
279
  ];
373
280
  }
374
281
 
@@ -443,44 +350,104 @@ export const UploadMixin = (superClass) =>
443
350
  super.i18n = value;
444
351
  }
445
352
 
446
- /** @private */
447
- get __acceptRegexp() {
448
- if (!this.accept) {
449
- return null;
450
- }
451
- const processedTokens = this.accept.split(',').map((token) => {
452
- let processedToken = token.trim();
453
- // Escape regex operators common to mime types
454
- processedToken = processedToken.replace(/[+.]/gu, '\\$&');
455
- // Make extension patterns match the end of the file name
456
- if (processedToken.startsWith('\\.')) {
457
- processedToken = `.*${processedToken}$`;
458
- }
459
- // Handle star (*) wildcards
460
- return processedToken.replace(/\/\*/gu, '/.*');
461
- });
462
- // Create accept regex
463
- return new RegExp(`^(${processedTokens.join('|')})$`, 'iu');
353
+ constructor() {
354
+ super();
355
+
356
+ this._manager = new InternalUploadManager(this);
357
+
358
+ // Files that are uploaded without being added to the `files` list
359
+ // (e.g. passed directly to `uploadFiles`)
360
+ this.__externalFiles = new Set();
464
361
  }
465
362
 
466
363
  /** @protected */
467
364
  ready() {
468
365
  super.ready();
366
+
367
+ // Set up manager event listeners
368
+ this._manager.addEventListener('files-changed', (e) => this.__onManagerFilesChanged(e));
369
+ this._manager.addEventListener('file-reject', (e) => this.__onManagerFileReject(e));
370
+ this._manager.addEventListener('file-remove', (e) => this.__onManagerFileRemove(e));
371
+ this._manager.addEventListener('upload-success', (e) => this.__onManagerUploadSuccess(e));
372
+ this._manager.addEventListener('upload-error', (e) => this.__onManagerUploadError(e));
373
+ this._manager.addEventListener('upload-retry', (e) => this.__onManagerUploadRetry(e));
374
+ this._manager.addEventListener('upload-before', (e) => {
375
+ // The manager assigns formDataName when a file is added, but the
376
+ // component has historically assigned it when the upload starts, so
377
+ // that later changes to the property also apply to files that were
378
+ // already added. Listeners of the redispatched event can still
379
+ // override the value before the request body is created.
380
+ if (this.uploadFormat !== 'raw') {
381
+ e.detail.file.formDataName = this.formDataName;
382
+ }
383
+ this.__redispatchEvent(e);
384
+ if (!e.defaultPrevented && typeof this.headers === 'string') {
385
+ // The component has historically parsed a headers JSON string into
386
+ // the property when an upload starts, after the upload-before
387
+ // listeners have run
388
+ this.headers = parseHeaders(this.headers);
389
+ }
390
+ });
391
+ this._manager.addEventListener('upload-progress', (e) => {
392
+ this.__applyLegacyProgressStats(e.detail.file);
393
+ this.__redispatchEvent(e);
394
+ });
395
+ ['upload-request', 'upload-start', 'upload-response', 'upload-abort'].forEach((type) =>
396
+ this._manager.addEventListener(type, (e) => this.__redispatchEvent(e)),
397
+ );
398
+
469
399
  this.addEventListener('dragover', this._onDragover.bind(this));
470
400
  this.addEventListener('dragleave', this._onDragleave.bind(this));
471
401
  this.addEventListener('drop', this._onDrop.bind(this));
472
- this.addEventListener('file-retry', this._onFileRetry.bind(this));
473
- this.addEventListener('file-abort', this._onFileAbort.bind(this));
474
- this.addEventListener('file-start', this._onFileStart.bind(this));
475
- this.addEventListener('file-reject', this._onFileReject.bind(this));
476
- this.addEventListener('upload-start', this._onUploadStart.bind(this));
477
- this.addEventListener('upload-success', this._onUploadSuccess.bind(this));
478
- this.addEventListener('upload-error', this._onUploadError.bind(this));
479
-
480
- this._addButtonController = new AddButtonController(this);
402
+
403
+ // Handle events dispatched by the file elements in the list
404
+ this.addEventListener('file-start', (e) => {
405
+ this.__clearFileError(e.detail.file);
406
+ this.__trackExternalFile(e.detail.file);
407
+ this._manager.startUpload(e.detail.file);
408
+ });
409
+ this.addEventListener('file-abort', (e) => {
410
+ const { file } = e.detail;
411
+ this._manager.abortUpload(file);
412
+ if (file.abort) {
413
+ this.__externalFiles.delete(file);
414
+ }
415
+ });
416
+ this.addEventListener('file-retry', (e) => this._manager.retryUpload(e.detail.file));
417
+
418
+ // Announce the upload lifecycle to screen readers
419
+ const alert = (message) => announce(message, { mode: 'alert' });
420
+ this.addEventListener('file-reject', (e) => alert(`${e.detail.file.name}: ${e.detail.error}`));
421
+ this.addEventListener('upload-start', (e) => alert(`${e.detail.file.name}: 0%`));
422
+ this.addEventListener('upload-success', (e) => alert(`${e.detail.file.name}: 100%`));
423
+ this.addEventListener('upload-error', (e) => alert(`${e.detail.file.name}: ${e.detail.file.error}`));
424
+
425
+ this._addButtonController = new SlotController(this, 'add-button', 'vaadin-button', {
426
+ initializer: (node) => {
427
+ // Needed by Flow counterpart to apply i18n to custom button
428
+ if (node._isDefault) {
429
+ this._addButtonController.defaultNode = node;
430
+ }
431
+ node.addEventListener('touchend', (e) => {
432
+ // Cancel the event to avoid the following click event
433
+ e.preventDefault();
434
+ this._onAddFilesClick(e);
435
+ });
436
+ node.addEventListener('click', (e) => this._onAddFilesClick(e));
437
+ this._addButton = node;
438
+ },
439
+ });
481
440
  this.addController(this._addButtonController);
482
441
 
483
- this._dropLabelController = new DropLabelController(this);
442
+ this._dropLabelController = new SlotController(this, 'drop-label', 'span', {
443
+ initializer: (label) => {
444
+ // Needed by Flow counterpart to apply i18n to custom label
445
+ if (label._isDefault) {
446
+ this._dropLabelController.defaultNode = label;
447
+ }
448
+ this._dropLabel = label;
449
+ },
450
+ });
484
451
  this.addController(this._dropLabelController);
485
452
 
486
453
  this.addController(
@@ -495,66 +462,225 @@ export const UploadMixin = (superClass) =>
495
462
  }
496
463
 
497
464
  /** @private */
498
- _formatSize(bytes) {
499
- if (typeof this.__effectiveI18n.formatSize === 'function') {
500
- return this.__effectiveI18n.formatSize(bytes);
501
- }
465
+ __updateMaxFilesReached(maxFiles, files) {
466
+ this._setMaxFilesReached(maxFiles >= 0 && files.length >= maxFiles);
467
+ }
502
468
 
503
- // https://wiki.ubuntu.com/UnitsPolicy
504
- const base = this.__effectiveI18n.units.sizeBase || 1000;
505
- const unit = ~~(Math.log(bytes) / Math.log(base));
506
- const dec = Math.max(0, Math.min(3, unit - 1));
507
- const size = parseFloat((bytes / base ** unit).toFixed(dec));
508
- return `${size} ${this.__effectiveI18n.units.size[unit]}`;
469
+ /**
470
+ * Restore the `speed` and `remaining` stats the component computed
471
+ * before the manager integration: the speed derived from the total file
472
+ * size instead of the transferred bytes, and unknown (infinite)
473
+ * remaining time when no bytes have been transferred yet. The status
474
+ * strings derived from the stats are recomputed by `__redispatchEvent`
475
+ * before the `upload-progress` listeners run.
476
+ * @private
477
+ */
478
+ __applyLegacyProgressStats(file) {
479
+ if (file.uploading && !file.errorKey && !file.abort && file.total && file.elapsed) {
480
+ file.speed = ~~(file.total / file.elapsed / 1024);
481
+ if (!file.loaded) {
482
+ file.remaining = Infinity;
483
+ }
484
+ }
509
485
  }
510
486
 
511
487
  /** @private */
512
- _splitTimeByUnits(time) {
513
- const unitSizes = [60, 60, 24, Infinity];
514
- const timeValues = [0];
488
+ __filesChanged(files) {
489
+ // Sync files to manager when set directly (e.g., from tests or user code)
490
+ // Skip if this change was triggered by the manager's files-changed event
491
+ if (this._manager && !this.__updatingFromManager) {
492
+ // The flag suppresses the synchronous file list render for the
493
+ // resulting files-changed event, which the component has
494
+ // historically not done for property assignments
495
+ this.__syncingFilesToManager = true;
496
+ this._manager.files = files;
497
+ this.__syncingFilesToManager = false;
498
+ }
499
+ }
515
500
 
516
- for (let i = 0; i < unitSizes.length && time > 0; i++) {
517
- timeValues[i] = time % unitSizes[i];
518
- time = Math.floor(time / unitSizes[i]);
501
+ // ============ Manager event handlers ============
502
+
503
+ /**
504
+ * Update the status strings of the given file, hiding the status when an
505
+ * error has been assigned to the file (e.g. by an `upload-progress`
506
+ * listener) while it is uploading, like the component historically did.
507
+ * @private
508
+ */
509
+ __updateFileStatus(file) {
510
+ updateFileStatus(file, this.__effectiveI18n);
511
+ if (file.uploading && file.error) {
512
+ file.status = undefined;
513
+ file.indeterminate = undefined;
514
+ }
515
+ }
516
+
517
+ /** @private */
518
+ __onManagerFilesChanged(event) {
519
+ const files = event.detail.value;
520
+ // Only update the `files` property when files are added or removed, so that
521
+ // `files-changed` is not fired for upload state updates on individual files
522
+ const filesChanged = files.length !== this.files.length || files.some((f, i) => f !== this.files[i]);
523
+ if (filesChanged) {
524
+ // Use flag to prevent recursive sync back to manager
525
+ this.__updatingFromManager = true;
526
+ this.files = [...files];
527
+ this.__updatingFromManager = false;
528
+ // The component has historically not rendered the file list
529
+ // synchronously when files are added or removed — the list is
530
+ // updated through the files property observer instead. Only compute
531
+ // the file status strings, which the file list does not compute
532
+ // when rendering.
533
+ this.__updateFileStatuses();
534
+ } else if (!this.__syncingFilesToManager) {
535
+ // Compute the file status strings before rendering, like the
536
+ // component did before the manager integration; the file list does
537
+ // not compute them when rendering.
538
+ this.__updateFileStatuses();
539
+ this.__renderFileList();
519
540
  }
541
+ }
542
+
543
+ /**
544
+ * Update the status strings of files, including files that are uploaded
545
+ * without being added to the `files` list, which are not rendered by the
546
+ * file list but still need up-to-date status strings. Only files that
547
+ * are uploading or queued are updated: the status of other files stays
548
+ * the same until they are queued again.
549
+ * @private
550
+ */
551
+ __updateFileStatuses() {
552
+ [...this.__externalFiles, ...this.files]
553
+ .filter((file) => file.uploading || file.held)
554
+ .forEach((file) => this.__updateFileStatus(file));
555
+ }
520
556
 
521
- return timeValues;
557
+ /**
558
+ * Track a file that is uploaded without being in the `files` list, so
559
+ * that its status strings are also updated while it uploads. The file is
560
+ * removed from the tracked set once its upload finishes or is aborted.
561
+ * @private
562
+ */
563
+ __trackExternalFile(file) {
564
+ if (!this.files.includes(file)) {
565
+ this.__externalFiles.add(file);
566
+ }
522
567
  }
523
568
 
524
569
  /** @private */
525
- _formatTime(seconds, split) {
526
- if (typeof this.__effectiveI18n.formatTime === 'function') {
527
- return this.__effectiveI18n.formatTime(seconds, split);
570
+ __renderFileList() {
571
+ if (this._fileList && typeof this._fileList.requestContentUpdate === 'function') {
572
+ this._fileList.requestContentUpdate();
528
573
  }
574
+ }
575
+
576
+ /** @private */
577
+ __onManagerFileReject(event) {
578
+ const { file, error } = event.detail;
579
+ // Translate error code to i18n message
580
+ const errorMessage = this.__effectiveI18n.error[error] || error;
581
+ this.dispatchEvent(
582
+ new CustomEvent('file-reject', {
583
+ detail: { file, error: errorMessage },
584
+ }),
585
+ );
586
+ }
587
+
588
+ /** @private */
589
+ __onManagerFileRemove(event) {
590
+ const { file, fileIndex } = event.detail;
529
591
 
530
- // Fill HH:MM:SS with leading zeros
531
- while (split.length < 3) {
532
- split.push(0);
592
+ this.dispatchEvent(
593
+ new CustomEvent('file-remove', {
594
+ detail: { file },
595
+ bubbles: true,
596
+ composed: true,
597
+ }),
598
+ );
599
+ this._updateFocus(fileIndex);
600
+ }
601
+
602
+ /** @private */
603
+ __redispatchEvent(event) {
604
+ const { file } = event.detail;
605
+ if (file) {
606
+ // Make sure the status strings are up-to-date when listeners of the
607
+ // redispatched event read them, like they were historically: e.g.
608
+ // 'Connecting...' already at the upload-request event, while the
609
+ // file list only renders it on the next files-changed
610
+ this.__updateFileStatus(file);
611
+ }
612
+ const dispatched = this.dispatchEvent(
613
+ new CustomEvent(event.type, {
614
+ detail: event.detail,
615
+ cancelable: event.cancelable,
616
+ }),
617
+ );
618
+ if (event.cancelable && !dispatched) {
619
+ event.preventDefault();
533
620
  }
621
+ }
534
622
 
535
- return split
536
- .reverse()
537
- .map((number) => {
538
- return (number < 10 ? '0' : '') + number;
539
- })
540
- .join(':');
623
+ /** @private */
624
+ __onManagerUploadSuccess(event) {
625
+ const { file } = event.detail;
626
+ this.__externalFiles.delete(file);
627
+ // Check if error was set by upload-response listener (for backwards compatibility)
628
+ if (file.error) {
629
+ file.complete = false;
630
+ this.dispatchEvent(new CustomEvent('upload-error', { detail: event.detail }));
631
+ return;
632
+ }
633
+ this.__redispatchEvent(event);
541
634
  }
542
635
 
543
636
  /** @private */
544
- _formatFileProgress(file) {
545
- const remainingTime =
546
- file.loaded > 0
547
- ? this.__effectiveI18n.uploading.remainingTime.prefix + file.remainingStr
548
- : this.__effectiveI18n.uploading.remainingTime.unknown;
637
+ __onManagerUploadRetry(event) {
638
+ this.__redispatchEvent(event);
639
+ if (!event.defaultPrevented) {
640
+ this.__clearFileError(event.detail.file);
549
641
 
550
- return `${file.totalStr}: ${file.progress}% (${remainingTime})`;
642
+ const fileIndex = this.files.indexOf(event.detail.file);
643
+ if (fileIndex >= 0) {
644
+ this._updateFocus(fileIndex);
645
+ }
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Clear the error from a previous upload attempt when the upload of the
651
+ * file is about to be restarted. The manager resets its own error state
652
+ * (`errorKey`) when queueing a file, but not the translated `error` message
653
+ * that this mixin assigns.
654
+ * @private
655
+ */
656
+ __clearFileError(file) {
657
+ if (!file.uploading) {
658
+ file.error = false;
659
+ }
551
660
  }
552
661
 
553
662
  /** @private */
554
- __updateMaxFilesReached(maxFiles, files) {
555
- this._setMaxFilesReached(maxFiles >= 0 && files.length >= maxFiles);
663
+ __onManagerUploadError(event) {
664
+ const { file } = event.detail;
665
+ this.__externalFiles.delete(file);
666
+ // Translate errorKey to i18n message and set file.error, also when a
667
+ // listener has already assigned an error, which the component has
668
+ // historically overwritten with the message derived from the response.
669
+ // An error key without an i18n message (e.g. a failure to send the
670
+ // request) leaves the assigned error untouched.
671
+ const error = file.errorKey && translateErrorKey(file.errorKey, this.__effectiveI18n);
672
+ if (error) {
673
+ file.error = error;
674
+ }
675
+ this.__redispatchEvent(event);
676
+ // The manager stops tracking upload progress once the upload has
677
+ // failed, so clear the stale status and progress state here
678
+ file.status = undefined;
679
+ file.indeterminate = undefined;
556
680
  }
557
681
 
682
+ // ============ UI updates ============
683
+
558
684
  /** @private */
559
685
  __updateAddButton(addButton, maxFiles, effectiveI18n, maxFilesReached, disabled) {
560
686
  if (addButton) {
@@ -581,14 +707,22 @@ export const UploadMixin = (superClass) =>
581
707
  list.items = [...files];
582
708
  list.i18n = effectiveI18n;
583
709
  list.disabled = disabled;
584
- if (this._theme) {
585
- list.setAttribute('theme', this._theme);
586
- } else {
587
- list.removeAttribute('theme');
588
- }
710
+ setOrRemoveAttribute(list, 'theme', this._theme);
589
711
  }
590
712
  }
591
713
 
714
+ // ============ Drag and drop ============
715
+
716
+ /** @private */
717
+ _dragoverChanged(dragover) {
718
+ setOrRemoveAttribute(this, 'dragover', dragover);
719
+ }
720
+
721
+ /** @private */
722
+ _dragoverValidChanged(dragoverValid) {
723
+ setOrRemoveAttribute(this, 'dragover-valid', dragoverValid);
724
+ }
725
+
592
726
  /** @private */
593
727
  _onDragover(event) {
594
728
  event.preventDefault();
@@ -618,299 +752,27 @@ export const UploadMixin = (superClass) =>
618
752
  }
619
753
  }
620
754
 
621
- /** @private */
622
- _createXhr() {
623
- return new XMLHttpRequest();
624
- }
625
-
626
- /** @private */
627
- _configureXhr(xhr, file = null, isRawUpload = false) {
628
- if (typeof this.headers === 'string') {
629
- try {
630
- this.headers = JSON.parse(this.headers);
631
- } catch (_) {
632
- this.headers = undefined;
633
- }
634
- }
635
- Object.entries(this.headers).forEach(([key, value]) => {
636
- xhr.setRequestHeader(key, value);
637
- });
638
-
639
- // Set Content-Type and filename header for raw binary uploads
640
- if (isRawUpload && file) {
641
- xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
642
- xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
643
- }
644
-
645
- if (this.timeout) {
646
- xhr.timeout = this.timeout;
647
- }
648
- xhr.withCredentials = this.withCredentials;
649
- }
650
-
651
- /** @private */
652
- _setStatus(file, total, loaded, elapsed) {
653
- file.elapsed = elapsed;
654
- file.elapsedStr = this._formatTime(file.elapsed, this._splitTimeByUnits(file.elapsed));
655
- file.remaining = Math.ceil(elapsed * (total / loaded - 1));
656
- file.remainingStr = this._formatTime(file.remaining, this._splitTimeByUnits(file.remaining));
657
- file.speed = ~~(total / elapsed / 1024);
658
- file.totalStr = this._formatSize(total);
659
- file.loadedStr = this._formatSize(loaded);
660
- file.status = this._formatFileProgress(file);
661
- }
662
-
663
- /**
664
- * Triggers the upload of any files that are not completed
665
- *
666
- * @param {!UploadFile | !Array<!UploadFile>=} files - Files being uploaded. Defaults to all outstanding files
667
- */
668
- uploadFiles(files = this.files) {
669
- if (files && !Array.isArray(files)) {
670
- files = [files];
671
- }
672
- files.filter((file) => !file.complete).forEach((file) => this._queueFileUpload(file));
673
- }
674
-
675
- /** @private */
676
- _queueFileUpload(file) {
677
- if (file.uploading) {
678
- return;
679
- }
680
-
681
- file.held = true;
682
- file.uploading = file.indeterminate = true;
683
- file.complete = file.abort = file.error = false;
684
- file.status = this.__effectiveI18n.uploading.status.held;
685
- this._renderFileList();
686
-
687
- this._uploadQueue.push(file);
688
- this._processUploadQueue();
689
- }
690
-
691
- /**
692
- * Process the upload queue by starting uploads for queued files
693
- * if there is available capacity.
694
- *
695
- * @private
696
- */
697
- _processUploadQueue() {
698
- // Process as many queued files as we have capacity for
699
- while (this._uploadQueue.length > 0 && this._activeUploads < this.maxConcurrentUploads) {
700
- const nextFile = this._uploadQueue.shift();
701
- if (nextFile) {
702
- this._uploadFile(nextFile);
703
- }
704
- }
705
- }
755
+ // ============ File input handling ============
706
756
 
707
757
  /** @private */
708
- _uploadFile(file) {
709
- // Increment active uploads counter
710
- this._activeUploads += 1;
711
-
712
- const ini = Date.now();
713
- const xhr = (file.xhr = this._createXhr());
714
-
715
- let stalledId, last;
716
- // Onprogress is called always after onreadystatechange
717
- xhr.upload.onprogress = (e) => {
718
- clearTimeout(stalledId);
719
-
720
- last = Date.now();
721
- const elapsed = (last - ini) / 1000;
722
- const loaded = e.loaded,
723
- total = e.total,
724
- progress = ~~((loaded / total) * 100);
725
- file.loaded = loaded;
726
- file.progress = progress;
727
- file.indeterminate = loaded <= 0 || loaded >= total;
728
-
729
- if (file.error) {
730
- file.indeterminate = file.status = undefined;
731
- } else if (!file.abort) {
732
- if (progress < 100) {
733
- this._setStatus(file, total, loaded, elapsed);
734
- stalledId = setTimeout(() => {
735
- file.status = this.__effectiveI18n.uploading.status.stalled;
736
- this._renderFileList();
737
- }, 2000);
738
- } else {
739
- file.loadedStr = file.totalStr;
740
- file.status = this.__effectiveI18n.uploading.status.processing;
741
- }
742
- }
743
-
744
- this._renderFileList();
745
- this.dispatchEvent(new CustomEvent('upload-progress', { detail: { file, xhr } }));
746
- };
747
-
748
- xhr.onabort = () => {
749
- // Decrement active uploads counter
750
- this._activeUploads -= 1;
751
- this._processUploadQueue();
752
- };
753
-
754
- // More reliable than xhr.onload
755
- xhr.onreadystatechange = () => {
756
- if (xhr.readyState === 4) {
757
- clearTimeout(stalledId);
758
- file.indeterminate = file.uploading = false;
759
-
760
- // Decrement active uploads counter
761
- this._activeUploads -= 1;
762
- this._processUploadQueue();
763
-
764
- if (file.abort) {
765
- return;
766
- }
767
- file.status = '';
768
- // Custom listener can modify the default behavior either
769
- // preventing default, changing the xhr, or setting the file error
770
- const evt = this.dispatchEvent(
771
- new CustomEvent('upload-response', {
772
- detail: { file, xhr },
773
- cancelable: true,
774
- }),
775
- );
776
-
777
- if (!evt) {
778
- return;
779
- }
780
- if (xhr.status === 0) {
781
- file.error = this.__effectiveI18n.uploading.error.serverUnavailable;
782
- } else if (xhr.status >= 500) {
783
- file.error = this.__effectiveI18n.uploading.error.unexpectedServerError;
784
- } else if (xhr.status === 413) {
785
- file.error = this.__effectiveI18n.uploading.error.fileTooLarge;
786
- } else if (xhr.status >= 400) {
787
- file.error = this.__effectiveI18n.uploading.error.forbidden;
788
- }
789
-
790
- file.complete = !file.error;
791
- this.dispatchEvent(
792
- new CustomEvent(`upload-${file.error ? 'error' : 'success'}`, {
793
- detail: { file, xhr },
794
- }),
795
- );
796
- this._renderFileList();
797
- }
798
- };
799
-
800
- // Determine upload format and prepare request body
801
- const isRawUpload = this.uploadFormat === 'raw';
802
-
803
- if (!file.uploadTarget) {
804
- file.uploadTarget = this.target || '';
805
- }
806
-
807
- // Only set formDataName for multipart uploads
808
- if (!isRawUpload) {
809
- file.formDataName = this.formDataName;
810
- }
811
-
812
- const evt = this.dispatchEvent(
813
- new CustomEvent('upload-before', {
814
- detail: { file, xhr },
815
- cancelable: true,
816
- }),
817
- );
818
- if (!evt) {
758
+ _onAddFilesClick(e) {
759
+ if (this.maxFilesReached) {
819
760
  return;
820
761
  }
821
762
 
822
- let requestBody;
823
- if (isRawUpload) {
824
- // Raw binary upload - send file directly
825
- requestBody = file;
826
- } else {
827
- // Multipart upload - use FormData
828
- const formData = new FormData();
829
- formData.append(file.formDataName, file, file.name);
830
- requestBody = formData;
831
- }
832
-
833
- xhr.open(this.method, file.uploadTarget, true);
834
- this._configureXhr(xhr, file, isRawUpload);
835
-
836
- file.held = false;
837
- file.status = this.__effectiveI18n.uploading.status.connecting;
838
-
839
- xhr.upload.onloadstart = () => {
840
- this.dispatchEvent(
841
- new CustomEvent('upload-start', {
842
- detail: { file, xhr },
843
- }),
844
- );
845
- this._renderFileList();
846
- };
847
-
848
- // Custom listener could modify the xhr just before sending it
849
- // preventing default
850
- const eventDetail = {
851
- file,
852
- xhr,
853
- uploadFormat: this.uploadFormat,
854
- requestBody,
855
- };
856
-
857
- // Expose formData property when using multipart so listeners can modify it
858
- if (!isRawUpload) {
859
- eventDetail.formData = requestBody;
860
- }
861
-
862
- const uploadEvt = this.dispatchEvent(
863
- new CustomEvent('upload-request', {
864
- detail: eventDetail,
865
- cancelable: true,
866
- }),
867
- );
868
- if (uploadEvt) {
869
- xhr.send(requestBody);
870
- }
871
- }
872
-
873
- /** @private */
874
- _retryFileUpload(file) {
875
- const evt = this.dispatchEvent(
876
- new CustomEvent('upload-retry', {
877
- detail: { file, xhr: file.xhr },
878
- cancelable: true,
879
- }),
880
- );
881
- if (evt) {
882
- this._queueFileUpload(file);
883
- this._updateFocus(this.files.indexOf(file));
884
- }
885
- }
886
-
887
- /** @private */
888
- _abortFileUpload(file) {
889
- const evt = this.dispatchEvent(
890
- new CustomEvent('upload-abort', {
891
- detail: { file, xhr: file.xhr },
892
- cancelable: true,
893
- }),
894
- );
895
- if (evt) {
896
- file.abort = true;
897
- if (file.xhr) {
898
- file.xhr.abort();
899
- }
900
- this._removeFile(file);
901
- }
763
+ e.stopPropagation();
764
+ this.$.fileInput.value = '';
765
+ this.$.fileInput.click();
902
766
  }
903
767
 
904
768
  /** @private */
905
- _renderFileList() {
906
- if (this._fileList && typeof this._fileList.requestContentUpdate === 'function') {
907
- this._fileList.requestContentUpdate();
908
- }
769
+ _onFileInputChange(event) {
770
+ this._addFiles(event.target.files);
909
771
  }
910
772
 
911
773
  /** @private */
912
774
  _addFiles(files) {
913
- Array.prototype.forEach.call(files, this._addFile.bind(this));
775
+ Array.from(files).forEach((file) => this._addFile(file));
914
776
  }
915
777
 
916
778
  /**
@@ -920,154 +782,76 @@ export const UploadMixin = (superClass) =>
920
782
  * @protected
921
783
  */
922
784
  _addFile(file) {
923
- if (this.maxFilesReached) {
924
- this.dispatchEvent(
925
- new CustomEvent('file-reject', {
926
- detail: { file, error: this.__effectiveI18n.error.tooManyFiles },
927
- }),
928
- );
929
- return;
930
- }
931
- if (this.maxFileSize >= 0 && file.size > this.maxFileSize) {
932
- this.dispatchEvent(
933
- new CustomEvent('file-reject', {
934
- detail: { file, error: this.__effectiveI18n.error.fileIsTooBig },
935
- }),
936
- );
937
- return;
938
- }
939
- const re = this.__acceptRegexp;
940
- if (re && !(re.test(file.type) || re.test(file.name))) {
941
- this.dispatchEvent(
942
- new CustomEvent('file-reject', {
943
- detail: { file, error: this.__effectiveI18n.error.incorrectFileType },
944
- }),
945
- );
946
- return;
947
- }
948
- file.loaded = 0;
949
- file.held = true;
950
- file.status = this.__effectiveI18n.uploading.status.held;
951
- this.files = [file, ...this.files];
952
-
953
- if (!this.noAuto) {
954
- this._queueFileUpload(file);
955
- }
956
- }
957
-
958
- /** @private */
959
- _updateFocus(fileIndex) {
960
- if (this.files.length === 0) {
961
- this._addButton.focus({ focusVisible: isKeyboardActive() });
962
- return;
963
- }
964
- const lastFileRemoved = fileIndex === this.files.length;
965
- if (lastFileRemoved) {
966
- fileIndex -= 1;
967
- }
968
- this._fileList.children[fileIndex].firstElementChild.focus({ focusVisible: isKeyboardActive() });
785
+ this._manager.addFiles([file]);
969
786
  }
970
787
 
971
788
  /**
972
789
  * Remove file from upload list. Called internally if file upload was canceled.
790
+ * Does nothing when called without a file.
973
791
  * @param {!UploadFile} file File to remove
974
792
  * @protected
975
793
  */
976
794
  _removeFile(file) {
977
- this._uploadQueue = this._uploadQueue.filter((f) => f !== file);
978
- this._processUploadQueue();
979
-
980
- const fileIndex = this.files.indexOf(file);
981
- if (fileIndex >= 0) {
982
- this.files = this.files.filter((i) => i !== file);
983
-
984
- this.dispatchEvent(
985
- new CustomEvent('file-remove', {
986
- detail: { file },
987
- bubbles: true,
988
- composed: true,
989
- }),
990
- );
991
-
992
- this._updateFocus(fileIndex);
795
+ // Tolerate a missing file to keep the historical no-op behavior,
796
+ // which e.g. UploadElement.removeFile(index) in Flow relies on
797
+ if (!file) {
798
+ return;
993
799
  }
800
+ this._manager.removeFile(file);
994
801
  }
995
802
 
996
- /** @private */
997
- _onAddFilesTouchEnd(e) {
998
- // Cancel the event to avoid the following click event
999
- e.preventDefault();
1000
- this._onAddFilesClick(e);
1001
- }
803
+ // ============ Accessibility ============
1002
804
 
1003
805
  /** @private */
1004
- _onAddFilesClick(e) {
1005
- if (this.maxFilesReached) {
806
+ _updateFocus(fileIndex) {
807
+ if (this.files.length === 0) {
808
+ this._addButton.focus({ focusVisible: isKeyboardActive() });
1006
809
  return;
1007
810
  }
1008
-
1009
- e.stopPropagation();
1010
- this.$.fileInput.value = '';
1011
- this.$.fileInput.click();
1012
- }
1013
-
1014
- /** @private */
1015
- _onFileInputChange(event) {
1016
- this._addFiles(event.target.files);
1017
- }
1018
-
1019
- /** @private */
1020
- _onFileStart(event) {
1021
- this._queueFileUpload(event.detail.file);
1022
- }
1023
-
1024
- /** @private */
1025
- _onFileRetry(event) {
1026
- this._retryFileUpload(event.detail.file);
1027
- }
1028
-
1029
- /** @private */
1030
- _onFileAbort(event) {
1031
- this._abortFileUpload(event.detail.file);
811
+ // If the removed file was at the end, focus the new last file
812
+ const lastFileRemoved = fileIndex >= this.files.length;
813
+ if (lastFileRemoved) {
814
+ fileIndex = this.files.length - 1;
815
+ }
816
+ if (this._fileList && this._fileList.children[fileIndex]) {
817
+ this._fileList.children[fileIndex].firstElementChild.focus({ focusVisible: isKeyboardActive() });
818
+ }
1032
819
  }
1033
820
 
1034
- /** @private */
1035
- _onFileReject(event) {
1036
- announce(`${event.detail.file.name}: ${event.detail.error}`, { mode: 'alert' });
821
+ /**
822
+ * Getter/setter for _createXhr to allow tests to mock XHR creation.
823
+ * @private
824
+ */
825
+ get _createXhr() {
826
+ return this._manager._createXhr;
1037
827
  }
1038
828
 
1039
- /** @private */
1040
- _onUploadStart(event) {
1041
- announce(`${event.detail.file.name}: 0%`, { mode: 'alert' });
829
+ set _createXhr(value) {
830
+ this._manager._createXhr = value;
1042
831
  }
1043
832
 
1044
- /** @private */
1045
- _onUploadSuccess(event) {
1046
- announce(`${event.detail.file.name}: 100%`, { mode: 'alert' });
1047
- }
833
+ /**
834
+ * Triggers the upload of any files that are not completed
835
+ *
836
+ * @param {!UploadFile | !Array<!UploadFile>=} files - Files being uploaded. Defaults to all outstanding files
837
+ */
838
+ uploadFiles(files = this.files) {
839
+ // Convert to array if single file
840
+ if (files && !Array.isArray(files)) {
841
+ files = [files];
842
+ }
1048
843
 
1049
- /** @private */
1050
- _onUploadError(event) {
1051
- announce(`${event.detail.file.name}: ${event.detail.file.error}`, { mode: 'alert' });
1052
- }
844
+ files
845
+ .filter((file) => !file.complete)
846
+ .forEach((file) => {
847
+ this.__clearFileError(file);
848
+ this.__trackExternalFile(file);
849
+ });
1053
850
 
1054
- /** @private */
1055
- _dragoverChanged(dragover) {
1056
- if (dragover) {
1057
- this.setAttribute('dragover', dragover);
1058
- } else {
1059
- this.removeAttribute('dragover');
1060
- }
851
+ this._manager.uploadFiles(files);
1061
852
  }
1062
853
 
1063
- /** @private */
1064
- _dragoverValidChanged(dragoverValid) {
1065
- if (dragoverValid) {
1066
- this.setAttribute('dragover-valid', dragoverValid);
1067
- } else {
1068
- this.removeAttribute('dragover-valid');
1069
- }
1070
- }
854
+ // ============ Utilities ============
1071
855
 
1072
856
  /** @private */
1073
857
  _i18nPlural(value, plural) {