@vaadin/upload 25.3.0-alpha9 → 25.3.0-beta2

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.
@@ -9,7 +9,9 @@ import { isTouch } from '@vaadin/component-base/src/browser-utils.js';
9
9
  import { setOrRemoveAttribute } from '@vaadin/component-base/src/dom-utils.js';
10
10
  import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
11
11
  import { SlotController } from '@vaadin/component-base/src/slot-controller.js';
12
- 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';
13
15
 
14
16
  export const DEFAULT_I18N = {
15
17
  dropFiles: {
@@ -20,92 +22,12 @@ export const DEFAULT_I18N = {
20
22
  one: 'Upload File...',
21
23
  many: 'Upload Files...',
22
24
  },
23
- error: {
24
- tooManyFiles: 'Too Many Files.',
25
- fileIsTooBig: 'File is Too Big.',
26
- incorrectFileType: 'Incorrect File Type.',
27
- },
28
- uploading: {
29
- status: {
30
- connecting: 'Connecting...',
31
- stalled: 'Stalled',
32
- processing: 'Processing File...',
33
- held: 'Queued',
34
- },
35
- remainingTime: {
36
- prefix: 'remaining time: ',
37
- unknown: 'unknown remaining time',
38
- },
39
- error: {
40
- serverUnavailable: 'Upload failed, please try again later',
41
- unexpectedServerError: 'Upload failed due to server error',
42
- forbidden: 'Upload forbidden',
43
- fileTooLarge: 'File is too large',
44
- },
45
- },
46
- file: {
47
- retry: 'Retry',
48
- start: 'Start',
49
- remove: 'Remove',
50
- },
51
- units: {
52
- size: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
53
- },
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,
54
29
  };
55
30
 
56
- class AddButtonController extends SlotController {
57
- constructor(host) {
58
- super(host, 'add-button', 'vaadin-button');
59
- }
60
-
61
- /**
62
- * Override method inherited from `SlotController`
63
- * to add listeners to default and custom node.
64
- *
65
- * @param {Node} node
66
- * @protected
67
- * @override
68
- */
69
- initNode(node) {
70
- // Needed by Flow counterpart to apply i18n to custom button
71
- if (node._isDefault) {
72
- this.defaultNode = node;
73
- }
74
-
75
- node.addEventListener('touchend', (e) => {
76
- this.host._onAddFilesTouchEnd(e);
77
- });
78
-
79
- node.addEventListener('click', (e) => {
80
- this.host._onAddFilesClick(e);
81
- });
82
-
83
- this.host._addButton = node;
84
- }
85
- }
86
-
87
- class DropLabelController extends SlotController {
88
- constructor(host) {
89
- super(host, 'drop-label', 'span');
90
- }
91
-
92
- /**
93
- * Override method inherited from `SlotController`
94
- * to add listeners to default and custom node.
95
- *
96
- * @param {Node} node
97
- * @protected
98
- * @override
99
- */
100
- initNode(node) {
101
- // Needed by Flow counterpart to apply i18n to custom label
102
- if (node._isDefault) {
103
- this.defaultNode = node;
104
- }
105
- this.host._dropLabel = node;
106
- }
107
- }
108
-
109
31
  export const UploadMixin = (superClass) =>
110
32
  class UploadMixin extends I18nMixin(superClass) {
111
33
  static get properties() {
@@ -208,6 +130,7 @@ export const UploadMixin = (superClass) =>
208
130
  notify: true,
209
131
  value: () => [],
210
132
  sync: true,
133
+ observer: '__filesChanged',
211
134
  },
212
135
 
213
136
  /**
@@ -344,32 +267,15 @@ export const UploadMixin = (superClass) =>
344
267
  _fileList: {
345
268
  type: Object,
346
269
  },
347
-
348
- /** @private */
349
- _files: {
350
- type: Array,
351
- },
352
-
353
- /** @private */
354
- _uploadQueue: {
355
- type: Array,
356
- value: () => [],
357
- },
358
-
359
- /** @private */
360
- _activeUploads: {
361
- type: Number,
362
- value: 0,
363
- },
364
270
  };
365
271
  }
366
272
 
367
273
  static get observers() {
368
274
  return [
275
+ '__updateMaxFilesReached(maxFiles, files)',
369
276
  '__updateAddButton(_addButton, maxFiles, __effectiveI18n, maxFilesReached, disabled)',
370
277
  '__updateDropLabel(_dropLabel, maxFiles, __effectiveI18n)',
371
278
  '__updateFileList(_fileList, files, __effectiveI18n, disabled, _theme)',
372
- '__updateMaxFilesReached(maxFiles, files)',
373
279
  ];
374
280
  }
375
281
 
@@ -444,44 +350,104 @@ export const UploadMixin = (superClass) =>
444
350
  super.i18n = value;
445
351
  }
446
352
 
447
- /** @private */
448
- get __acceptRegexp() {
449
- if (!this.accept) {
450
- return null;
451
- }
452
- const processedTokens = this.accept.split(',').map((token) => {
453
- let processedToken = token.trim();
454
- // Escape regex operators common to mime types
455
- processedToken = processedToken.replace(/[+.]/gu, '\\$&');
456
- // Make extension patterns match the end of the file name
457
- if (processedToken.startsWith('\\.')) {
458
- processedToken = `.*${processedToken}$`;
459
- }
460
- // Handle star (*) wildcards
461
- return processedToken.replace(/\/\*/gu, '/.*');
462
- });
463
- // Create accept regex
464
- 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();
465
361
  }
466
362
 
467
363
  /** @protected */
468
364
  ready() {
469
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
+
470
399
  this.addEventListener('dragover', this._onDragover.bind(this));
471
400
  this.addEventListener('dragleave', this._onDragleave.bind(this));
472
401
  this.addEventListener('drop', this._onDrop.bind(this));
473
- this.addEventListener('file-retry', this._onFileRetry.bind(this));
474
- this.addEventListener('file-abort', this._onFileAbort.bind(this));
475
- this.addEventListener('file-start', this._onFileStart.bind(this));
476
- this.addEventListener('file-reject', this._onFileReject.bind(this));
477
- this.addEventListener('upload-start', this._onUploadStart.bind(this));
478
- this.addEventListener('upload-success', this._onUploadSuccess.bind(this));
479
- this.addEventListener('upload-error', this._onUploadError.bind(this));
480
-
481
- 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
+ });
482
440
  this.addController(this._addButtonController);
483
441
 
484
- 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
+ });
485
451
  this.addController(this._dropLabelController);
486
452
 
487
453
  this.addController(
@@ -496,66 +462,225 @@ export const UploadMixin = (superClass) =>
496
462
  }
497
463
 
498
464
  /** @private */
499
- _formatSize(bytes) {
500
- if (typeof this.__effectiveI18n.formatSize === 'function') {
501
- return this.__effectiveI18n.formatSize(bytes);
502
- }
465
+ __updateMaxFilesReached(maxFiles, files) {
466
+ this._setMaxFilesReached(maxFiles >= 0 && files.length >= maxFiles);
467
+ }
503
468
 
504
- // https://wiki.ubuntu.com/UnitsPolicy
505
- const base = this.__effectiveI18n.units.sizeBase || 1000;
506
- const unit = ~~(Math.log(bytes) / Math.log(base));
507
- const dec = Math.max(0, Math.min(3, unit - 1));
508
- const size = parseFloat((bytes / base ** unit).toFixed(dec));
509
- 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
+ }
510
485
  }
511
486
 
512
487
  /** @private */
513
- _splitTimeByUnits(time) {
514
- const unitSizes = [60, 60, 24, Infinity];
515
- 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
+ }
516
500
 
517
- for (let i = 0; i < unitSizes.length && time > 0; i++) {
518
- timeValues[i] = time % unitSizes[i];
519
- 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();
520
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
+ }
521
556
 
522
- 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
+ }
523
567
  }
524
568
 
525
569
  /** @private */
526
- _formatTime(seconds, split) {
527
- if (typeof this.__effectiveI18n.formatTime === 'function') {
528
- return this.__effectiveI18n.formatTime(seconds, split);
570
+ __renderFileList() {
571
+ if (this._fileList && typeof this._fileList.requestContentUpdate === 'function') {
572
+ this._fileList.requestContentUpdate();
529
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
+ }
530
587
 
531
- // Fill HH:MM:SS with leading zeros
532
- while (split.length < 3) {
533
- split.push(0);
588
+ /** @private */
589
+ __onManagerFileRemove(event) {
590
+ const { file, fileIndex } = event.detail;
591
+
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);
534
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();
620
+ }
621
+ }
535
622
 
536
- return split
537
- .reverse()
538
- .map((number) => {
539
- return (number < 10 ? '0' : '') + number;
540
- })
541
- .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);
542
634
  }
543
635
 
544
636
  /** @private */
545
- _formatFileProgress(file) {
546
- const remainingTime =
547
- file.loaded > 0
548
- ? this.__effectiveI18n.uploading.remainingTime.prefix + file.remainingStr
549
- : this.__effectiveI18n.uploading.remainingTime.unknown;
637
+ __onManagerUploadRetry(event) {
638
+ this.__redispatchEvent(event);
639
+ if (!event.defaultPrevented) {
640
+ this.__clearFileError(event.detail.file);
550
641
 
551
- 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
+ }
552
660
  }
553
661
 
554
662
  /** @private */
555
- __updateMaxFilesReached(maxFiles, files) {
556
- 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;
557
680
  }
558
681
 
682
+ // ============ UI updates ============
683
+
559
684
  /** @private */
560
685
  __updateAddButton(addButton, maxFiles, effectiveI18n, maxFilesReached, disabled) {
561
686
  if (addButton) {
@@ -586,6 +711,18 @@ export const UploadMixin = (superClass) =>
586
711
  }
587
712
  }
588
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
+
589
726
  /** @private */
590
727
  _onDragover(event) {
591
728
  event.preventDefault();
@@ -615,299 +752,27 @@ export const UploadMixin = (superClass) =>
615
752
  }
616
753
  }
617
754
 
618
- /** @private */
619
- _createXhr() {
620
- return new XMLHttpRequest();
621
- }
622
-
623
- /** @private */
624
- _configureXhr(xhr, file = null, isRawUpload = false) {
625
- if (typeof this.headers === 'string') {
626
- try {
627
- this.headers = JSON.parse(this.headers);
628
- } catch (_) {
629
- this.headers = undefined;
630
- }
631
- }
632
- Object.entries(this.headers).forEach(([key, value]) => {
633
- xhr.setRequestHeader(key, value);
634
- });
635
-
636
- // Set Content-Type and filename header for raw binary uploads
637
- if (isRawUpload && file) {
638
- xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
639
- xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
640
- }
641
-
642
- if (this.timeout) {
643
- xhr.timeout = this.timeout;
644
- }
645
- xhr.withCredentials = this.withCredentials;
646
- }
647
-
648
- /** @private */
649
- _setStatus(file, total, loaded, elapsed) {
650
- file.elapsed = elapsed;
651
- file.elapsedStr = this._formatTime(file.elapsed, this._splitTimeByUnits(file.elapsed));
652
- file.remaining = Math.ceil(elapsed * (total / loaded - 1));
653
- file.remainingStr = this._formatTime(file.remaining, this._splitTimeByUnits(file.remaining));
654
- file.speed = ~~(total / elapsed / 1024);
655
- file.totalStr = this._formatSize(total);
656
- file.loadedStr = this._formatSize(loaded);
657
- file.status = this._formatFileProgress(file);
658
- }
659
-
660
- /**
661
- * Triggers the upload of any files that are not completed
662
- *
663
- * @param {!UploadFile | !Array<!UploadFile>=} files - Files being uploaded. Defaults to all outstanding files
664
- */
665
- uploadFiles(files = this.files) {
666
- if (files && !Array.isArray(files)) {
667
- files = [files];
668
- }
669
- files.filter((file) => !file.complete).forEach((file) => this._queueFileUpload(file));
670
- }
755
+ // ============ File input handling ============
671
756
 
672
757
  /** @private */
673
- _queueFileUpload(file) {
674
- if (file.uploading) {
675
- return;
676
- }
677
-
678
- file.held = true;
679
- file.uploading = file.indeterminate = true;
680
- file.complete = file.abort = file.error = false;
681
- file.status = this.__effectiveI18n.uploading.status.held;
682
- this._renderFileList();
683
-
684
- this._uploadQueue.push(file);
685
- this._processUploadQueue();
686
- }
687
-
688
- /**
689
- * Process the upload queue by starting uploads for queued files
690
- * if there is available capacity.
691
- *
692
- * @private
693
- */
694
- _processUploadQueue() {
695
- // Process as many queued files as we have capacity for
696
- while (this._uploadQueue.length > 0 && this._activeUploads < this.maxConcurrentUploads) {
697
- const nextFile = this._uploadQueue.shift();
698
- if (nextFile) {
699
- this._uploadFile(nextFile);
700
- }
701
- }
702
- }
703
-
704
- /** @private */
705
- _uploadFile(file) {
706
- // Increment active uploads counter
707
- this._activeUploads += 1;
708
-
709
- const ini = Date.now();
710
- const xhr = (file.xhr = this._createXhr());
711
-
712
- let stalledId, last;
713
- // Onprogress is called always after onreadystatechange
714
- xhr.upload.onprogress = (e) => {
715
- clearTimeout(stalledId);
716
-
717
- last = Date.now();
718
- const elapsed = (last - ini) / 1000;
719
- const loaded = e.loaded,
720
- total = e.total,
721
- progress = ~~((loaded / total) * 100);
722
- file.loaded = loaded;
723
- file.progress = progress;
724
- file.indeterminate = loaded <= 0 || loaded >= total;
725
-
726
- if (file.error) {
727
- file.indeterminate = file.status = undefined;
728
- } else if (!file.abort) {
729
- if (progress < 100) {
730
- this._setStatus(file, total, loaded, elapsed);
731
- stalledId = setTimeout(() => {
732
- file.status = this.__effectiveI18n.uploading.status.stalled;
733
- this._renderFileList();
734
- }, 2000);
735
- } else {
736
- file.loadedStr = file.totalStr;
737
- file.status = this.__effectiveI18n.uploading.status.processing;
738
- }
739
- }
740
-
741
- this._renderFileList();
742
- this.dispatchEvent(new CustomEvent('upload-progress', { detail: { file, xhr } }));
743
- };
744
-
745
- xhr.onabort = () => {
746
- // Decrement active uploads counter
747
- this._activeUploads -= 1;
748
- this._processUploadQueue();
749
- };
750
-
751
- // More reliable than xhr.onload
752
- xhr.onreadystatechange = () => {
753
- if (xhr.readyState === 4) {
754
- clearTimeout(stalledId);
755
- file.indeterminate = file.uploading = false;
756
-
757
- // Decrement active uploads counter
758
- this._activeUploads -= 1;
759
- this._processUploadQueue();
760
-
761
- if (file.abort) {
762
- return;
763
- }
764
- file.status = '';
765
- // Custom listener can modify the default behavior either
766
- // preventing default, changing the xhr, or setting the file error
767
- const evt = this.dispatchEvent(
768
- new CustomEvent('upload-response', {
769
- detail: { file, xhr },
770
- cancelable: true,
771
- }),
772
- );
773
-
774
- if (!evt) {
775
- return;
776
- }
777
- if (xhr.status === 0) {
778
- file.error = this.__effectiveI18n.uploading.error.serverUnavailable;
779
- } else if (xhr.status >= 500) {
780
- file.error = this.__effectiveI18n.uploading.error.unexpectedServerError;
781
- } else if (xhr.status === 413) {
782
- file.error = this.__effectiveI18n.uploading.error.fileTooLarge;
783
- } else if (xhr.status >= 400) {
784
- file.error = this.__effectiveI18n.uploading.error.forbidden;
785
- }
786
-
787
- file.complete = !file.error;
788
- this.dispatchEvent(
789
- new CustomEvent(`upload-${file.error ? 'error' : 'success'}`, {
790
- detail: { file, xhr },
791
- }),
792
- );
793
- this._renderFileList();
794
- }
795
- };
796
-
797
- // Determine upload format and prepare request body
798
- const isRawUpload = this.uploadFormat === 'raw';
799
-
800
- if (!file.uploadTarget) {
801
- file.uploadTarget = this.target || '';
802
- }
803
-
804
- // Only set formDataName for multipart uploads
805
- if (!isRawUpload) {
806
- file.formDataName = this.formDataName;
807
- }
808
-
809
- const evt = this.dispatchEvent(
810
- new CustomEvent('upload-before', {
811
- detail: { file, xhr },
812
- cancelable: true,
813
- }),
814
- );
815
- if (!evt) {
758
+ _onAddFilesClick(e) {
759
+ if (this.maxFilesReached) {
816
760
  return;
817
761
  }
818
762
 
819
- let requestBody;
820
- if (isRawUpload) {
821
- // Raw binary upload - send file directly
822
- requestBody = file;
823
- } else {
824
- // Multipart upload - use FormData
825
- const formData = new FormData();
826
- formData.append(file.formDataName, file, file.name);
827
- requestBody = formData;
828
- }
829
-
830
- xhr.open(this.method, file.uploadTarget, true);
831
- this._configureXhr(xhr, file, isRawUpload);
832
-
833
- file.held = false;
834
- file.status = this.__effectiveI18n.uploading.status.connecting;
835
-
836
- xhr.upload.onloadstart = () => {
837
- this.dispatchEvent(
838
- new CustomEvent('upload-start', {
839
- detail: { file, xhr },
840
- }),
841
- );
842
- this._renderFileList();
843
- };
844
-
845
- // Custom listener could modify the xhr just before sending it
846
- // preventing default
847
- const eventDetail = {
848
- file,
849
- xhr,
850
- uploadFormat: this.uploadFormat,
851
- requestBody,
852
- };
853
-
854
- // Expose formData property when using multipart so listeners can modify it
855
- if (!isRawUpload) {
856
- eventDetail.formData = requestBody;
857
- }
858
-
859
- const uploadEvt = this.dispatchEvent(
860
- new CustomEvent('upload-request', {
861
- detail: eventDetail,
862
- cancelable: true,
863
- }),
864
- );
865
- if (uploadEvt) {
866
- xhr.send(requestBody);
867
- }
868
- }
869
-
870
- /** @private */
871
- _retryFileUpload(file) {
872
- const evt = this.dispatchEvent(
873
- new CustomEvent('upload-retry', {
874
- detail: { file, xhr: file.xhr },
875
- cancelable: true,
876
- }),
877
- );
878
- if (evt) {
879
- this._queueFileUpload(file);
880
- this._updateFocus(this.files.indexOf(file));
881
- }
882
- }
883
-
884
- /** @private */
885
- _abortFileUpload(file) {
886
- const evt = this.dispatchEvent(
887
- new CustomEvent('upload-abort', {
888
- detail: { file, xhr: file.xhr },
889
- cancelable: true,
890
- }),
891
- );
892
- if (evt) {
893
- file.abort = true;
894
- if (file.xhr) {
895
- file.xhr.abort();
896
- }
897
- this._removeFile(file);
898
- }
763
+ e.stopPropagation();
764
+ this.$.fileInput.value = '';
765
+ this.$.fileInput.click();
899
766
  }
900
767
 
901
768
  /** @private */
902
- _renderFileList() {
903
- if (this._fileList && typeof this._fileList.requestContentUpdate === 'function') {
904
- this._fileList.requestContentUpdate();
905
- }
769
+ _onFileInputChange(event) {
770
+ this._addFiles(event.target.files);
906
771
  }
907
772
 
908
773
  /** @private */
909
774
  _addFiles(files) {
910
- Array.prototype.forEach.call(files, this._addFile.bind(this));
775
+ Array.from(files).forEach((file) => this._addFile(file));
911
776
  }
912
777
 
913
778
  /**
@@ -917,146 +782,76 @@ export const UploadMixin = (superClass) =>
917
782
  * @protected
918
783
  */
919
784
  _addFile(file) {
920
- if (this.maxFilesReached) {
921
- this.dispatchEvent(
922
- new CustomEvent('file-reject', {
923
- detail: { file, error: this.__effectiveI18n.error.tooManyFiles },
924
- }),
925
- );
926
- return;
927
- }
928
- if (this.maxFileSize >= 0 && file.size > this.maxFileSize) {
929
- this.dispatchEvent(
930
- new CustomEvent('file-reject', {
931
- detail: { file, error: this.__effectiveI18n.error.fileIsTooBig },
932
- }),
933
- );
934
- return;
935
- }
936
- const re = this.__acceptRegexp;
937
- if (re && !(re.test(file.type) || re.test(file.name))) {
938
- this.dispatchEvent(
939
- new CustomEvent('file-reject', {
940
- detail: { file, error: this.__effectiveI18n.error.incorrectFileType },
941
- }),
942
- );
943
- return;
944
- }
945
- file.loaded = 0;
946
- file.held = true;
947
- file.status = this.__effectiveI18n.uploading.status.held;
948
- this.files = [file, ...this.files];
949
-
950
- if (!this.noAuto) {
951
- this._queueFileUpload(file);
952
- }
953
- }
954
-
955
- /** @private */
956
- _updateFocus(fileIndex) {
957
- if (this.files.length === 0) {
958
- this._addButton.focus({ focusVisible: isKeyboardActive() });
959
- return;
960
- }
961
- const lastFileRemoved = fileIndex === this.files.length;
962
- if (lastFileRemoved) {
963
- fileIndex -= 1;
964
- }
965
- this._fileList.children[fileIndex].firstElementChild.focus({ focusVisible: isKeyboardActive() });
785
+ this._manager.addFiles([file]);
966
786
  }
967
787
 
968
788
  /**
969
789
  * Remove file from upload list. Called internally if file upload was canceled.
790
+ * Does nothing when called without a file.
970
791
  * @param {!UploadFile} file File to remove
971
792
  * @protected
972
793
  */
973
794
  _removeFile(file) {
974
- this._uploadQueue = this._uploadQueue.filter((f) => f !== file);
975
- this._processUploadQueue();
976
-
977
- const fileIndex = this.files.indexOf(file);
978
- if (fileIndex >= 0) {
979
- this.files = this.files.filter((i) => i !== file);
980
-
981
- this.dispatchEvent(
982
- new CustomEvent('file-remove', {
983
- detail: { file },
984
- bubbles: true,
985
- composed: true,
986
- }),
987
- );
988
-
989
- 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;
990
799
  }
800
+ this._manager.removeFile(file);
991
801
  }
992
802
 
993
- /** @private */
994
- _onAddFilesTouchEnd(e) {
995
- // Cancel the event to avoid the following click event
996
- e.preventDefault();
997
- this._onAddFilesClick(e);
998
- }
803
+ // ============ Accessibility ============
999
804
 
1000
805
  /** @private */
1001
- _onAddFilesClick(e) {
1002
- if (this.maxFilesReached) {
806
+ _updateFocus(fileIndex) {
807
+ if (this.files.length === 0) {
808
+ this._addButton.focus({ focusVisible: isKeyboardActive() });
1003
809
  return;
1004
810
  }
1005
-
1006
- e.stopPropagation();
1007
- this.$.fileInput.value = '';
1008
- this.$.fileInput.click();
1009
- }
1010
-
1011
- /** @private */
1012
- _onFileInputChange(event) {
1013
- this._addFiles(event.target.files);
1014
- }
1015
-
1016
- /** @private */
1017
- _onFileStart(event) {
1018
- this._queueFileUpload(event.detail.file);
1019
- }
1020
-
1021
- /** @private */
1022
- _onFileRetry(event) {
1023
- this._retryFileUpload(event.detail.file);
1024
- }
1025
-
1026
- /** @private */
1027
- _onFileAbort(event) {
1028
- 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
+ }
1029
819
  }
1030
820
 
1031
- /** @private */
1032
- _onFileReject(event) {
1033
- 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;
1034
827
  }
1035
828
 
1036
- /** @private */
1037
- _onUploadStart(event) {
1038
- announce(`${event.detail.file.name}: 0%`, { mode: 'alert' });
829
+ set _createXhr(value) {
830
+ this._manager._createXhr = value;
1039
831
  }
1040
832
 
1041
- /** @private */
1042
- _onUploadSuccess(event) {
1043
- announce(`${event.detail.file.name}: 100%`, { mode: 'alert' });
1044
- }
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
+ }
1045
843
 
1046
- /** @private */
1047
- _onUploadError(event) {
1048
- announce(`${event.detail.file.name}: ${event.detail.file.error}`, { mode: 'alert' });
1049
- }
844
+ files
845
+ .filter((file) => !file.complete)
846
+ .forEach((file) => {
847
+ this.__clearFileError(file);
848
+ this.__trackExternalFile(file);
849
+ });
1050
850
 
1051
- /** @private */
1052
- _dragoverChanged(dragover) {
1053
- setOrRemoveAttribute(this, 'dragover', dragover);
851
+ this._manager.uploadFiles(files);
1054
852
  }
1055
853
 
1056
- /** @private */
1057
- _dragoverValidChanged(dragoverValid) {
1058
- setOrRemoveAttribute(this, 'dragover-valid', dragoverValid);
1059
- }
854
+ // ============ Utilities ============
1060
855
 
1061
856
  /** @private */
1062
857
  _i18nPlural(value, plural) {