ExamplesPlaygroundReference Source

coral-spectrum/coral-component-fileupload/src/scripts/FileUpload.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import {BaseComponent} from '../../../coral-base-component';
  14. import {BaseFormField} from '../../../coral-base-formfield';
  15. import FileUploadItem from './FileUploadItem';
  16. import base from '../templates/base';
  17. import {transform, commons, validate} from '../../../coral-utils';
  18. import {Decorator} from '../../../coral-decorator';
  19.  
  20. const CLASSNAME = '_coral-FileUpload';
  21.  
  22. const XHR_EVENT_NAMES = ['loadstart', 'progress', 'load', 'error', 'loadend', 'readystatechange', 'abort', 'timeout'];
  23.  
  24. /**
  25. Enumeration for {@link FileUpload} HTTP methods that can be used to upload files.
  26.  
  27. @typedef {Object} FileUploadMethodEnum
  28.  
  29. @property {String} POST
  30. Send a POST request. Used when creating a resource.
  31. @property {String} PUT
  32. Send a PUT request. Used when replacing a resource.
  33. @property {String} PATCH
  34. Send a PATCH request. Used when partially updating a resource.
  35. */
  36. const method = {
  37. POST: 'POST',
  38. PUT: 'PUT',
  39. PATCH: 'PATCH'
  40. };
  41.  
  42. /**
  43. @class Coral.FileUpload
  44. @classdesc A FileUpload component that manages the upload process of multiple files. Child elements of FileUpload can
  45. be given special attributes to enable functionality:
  46. - <code>[coral-fileupload-select]</code>. Click to choose file(s), replacing existing files.
  47. - <code>[coral-fileupload-dropzone]</code>. Drag and drop files to choose file(s), replacing existing files.
  48. - <code>[coral-fileupload-clear]</code>. Click to remove all files from the queue.
  49. - <code>[coral-fileupload-submit]</code>. Click to start uploading.
  50. - <code>[coral-fileupload-abort]</code>. Click to abort all uploads.
  51. - <code>[coral-fileupload-abortfile="filename.txt"]</code>. Click to abort a specific file, leaving it in the queue.
  52. - <code>[coral-fileupload-removefile="filename.txt"]</code>. Click to remove a specific file from the queue.
  53. - <code>[coral-fileupload-uploadfile="filename.txt"]</code>. Click to start uploading a specific file.
  54.  
  55. @htmltag coral-fileupload
  56. @extends {HTMLElement}
  57. @extends {BaseComponent}
  58. @extends {BaseFormField}
  59. */
  60. const FileUpload = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
  61. /** @ignore */
  62. constructor() {
  63. super();
  64.  
  65. // Events
  66. this._delegateEvents(commons.extend(this._events, {
  67. // Clickable hooks
  68. 'click [coral-fileupload-submit]': '_onSubmitButtonClick',
  69. 'click [coral-fileupload-clear]': 'clear',
  70. 'click [coral-fileupload-select]': '_showFileDialog',
  71. 'click [coral-fileupload-abort]': 'abort',
  72. 'click [coral-fileupload-abortfile]': '_onAbortFileClick',
  73. 'click [coral-fileupload-removefile]': '_onRemoveFileClick',
  74. 'click [coral-fileupload-uploadfile]': '_onUploadFileClick',
  75.  
  76. // Drag & Drop zones
  77. 'dragenter [coral-fileupload-dropzone]': '_onDragAndDrop',
  78. 'dragover [coral-fileupload-dropzone]': '_onDragAndDrop',
  79. 'dragleave [handle="input"]': '_onDragAndDrop',
  80. 'drop [handle="input"]': '_onDragAndDrop',
  81.  
  82. // Accessibility
  83. 'capture:focus [coral-fileupload-select]': '_onButtonFocusIn',
  84. 'capture:focus [handle="input"]': '_onInputFocusIn',
  85. 'capture:blur [handle="input"]': '_onInputFocusOut'
  86. }));
  87.  
  88. // Prepare templates
  89. this._elements = {};
  90. base.call(this._elements, {commons});
  91.  
  92. // Pre-define labellable element
  93. this._labellableElement = this._elements.input;
  94.  
  95. // Used for items
  96. this._uploadQueue = [];
  97.  
  98. // this should refer to the fileupload
  99. this._doAddDragClass = this._doAddDragClass.bind(this);
  100. this._doRemoveDragClass = this._doRemoveDragClass.bind(this);
  101. this._positionInputOnDropZone = this._positionInputOnDropZone.bind(this);
  102.  
  103. // Reposition the input under the specified dropzone
  104. this._observer = new MutationObserver(this._positionInputOnDropZone);
  105. this._observer.observe(this, {
  106. childList: true,
  107. attributes: true,
  108. attributeFilter: ['coral-fileupload-dropzone'],
  109. subtree: true
  110. });
  111. }
  112.  
  113. /**
  114. Name used to submit the data in a form.
  115. @type {String}
  116. @default ""
  117. @htmlattribute name
  118. @htmlattributereflected
  119. */
  120. get name() {
  121. return this._elements.input.name;
  122. }
  123.  
  124. set name(value) {
  125. this._reflectAttribute('name', value);
  126.  
  127. this._elements.input.name = value;
  128. }
  129.  
  130. /**
  131. This field's current value.
  132. @type {String}
  133. @default ""
  134. @htmlattribute value
  135. */
  136. get value() {
  137. const item = this._uploadQueue ? this._getQueueItem(0) : null;
  138.  
  139. // The first selected filename, or the empty string if no files are selected.
  140. return item ? `C:\\fakepath\\${item.file.name}` : '';
  141. }
  142.  
  143. set value(value) {
  144. if (value === '' || value === null) {
  145. this._clearQueue();
  146. this._clearFileInputValue();
  147. } else {
  148. // Throws exception if value is different than an empty string or null
  149. throw new Error('Coral.FileUpload accepts a filename, which may only be programmatically set to empty string.');
  150. }
  151. }
  152.  
  153. /**
  154. Whether this field is disabled or not.
  155. @type {Boolean}
  156. @default false
  157. @htmlattribute disabled
  158. @htmlattributereflected
  159. */
  160. get disabled() {
  161. return this._elements.input.disabled;
  162. }
  163.  
  164. set disabled(value) {
  165. this._elements.input.disabled = transform.booleanAttr(value);
  166. this._reflectAttribute('disabled', this.disabled);
  167.  
  168. this.classList.toggle('is-disabled', this.disabled);
  169. this._setElementState();
  170. }
  171.  
  172. /**
  173. Inherited from {@link BaseFormField#invalid}.
  174. */
  175. get invalid() {
  176. return super.invalid;
  177. }
  178.  
  179. set invalid(value) {
  180. super.invalid = value;
  181.  
  182. this._elements.input.setAttribute('aria-invalid', this.invalid);
  183. this._setElementState();
  184. }
  185.  
  186. /**
  187. Whether this field is required or not.
  188. @type {Boolean}
  189. @default false
  190. @htmlattribute required
  191. @htmlattributereflected
  192. */
  193. get required() {
  194. return this._elements.input.required;
  195. }
  196.  
  197. set required(value) {
  198. this._elements.input.required = transform.booleanAttr(value);
  199. this._reflectAttribute('required', this.required);
  200.  
  201. this.classList.toggle('is-required', this.required);
  202. this._setElementState();
  203. }
  204.  
  205. /**
  206. Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
  207. @type {Boolean}
  208. @default false
  209. @htmlattribute readonly
  210. @htmlattributereflected
  211. */
  212. get readOnly() {
  213. return this._readOnly || false;
  214. }
  215.  
  216. set readOnly(value) {
  217. this._readOnly = transform.booleanAttr(value);
  218. this._reflectAttribute('readonly', this._readOnly);
  219.  
  220. this._setElementState();
  221. }
  222.  
  223. /**
  224. The names of the currently selected files.
  225. When {@link Coral.FileUpload#multiple} is <code>false</code>, this will be an array of length 1.
  226.  
  227. @type {Array.<String>}
  228. */
  229. get values() {
  230. let values = this._uploadQueue.map((item) => `C:\\fakepath\\${item.file.name}`);
  231.  
  232. if (values.length && !this.multiple) {
  233. values = [values[0]];
  234. }
  235.  
  236. return values;
  237. }
  238.  
  239. set values(values) {
  240. if (Array.isArray(values)) {
  241. if (values.length) {
  242. this.value = values[0];
  243. } else {
  244. this.value = '';
  245. }
  246. }
  247. }
  248.  
  249. /**
  250. Inherited from {@link BaseFormField#labelledBy}.
  251. */
  252. get labelledBy() {
  253. return super.labelledBy;
  254. }
  255.  
  256. set labelledBy(value) {
  257. super.labelledBy = value;
  258.  
  259. // The specified labelledBy property.
  260. const labelledBy = this.labelledBy;
  261.  
  262. // An array of element ids to label control, the last being the select button element id.
  263. const ids = [];
  264.  
  265. const button = this.querySelector('[coral-fileupload-select]');
  266.  
  267. if (button) {
  268. ids.push(button.id);
  269. }
  270.  
  271. // If a labelledBy property exists,
  272. if (labelledBy) {
  273. // prepend the labelledBy value to the ids array
  274. ids.unshift(labelledBy);
  275. }
  276.  
  277. // Set aria-labelledby attribute on the labellable element joining ids array into space-delimited list of ids.
  278. this._elements.input.setAttribute('aria-labelledby', ids.join(' '));
  279.  
  280. if (labelledBy) {
  281. // Set label for attribute
  282. const labelElement = document.getElementById(labelledBy);
  283. if (labelElement && labelElement.tagName === 'LABEL') {
  284. labelElement.setAttribute('for', this._elements.input.id);
  285. this._labelElement = labelElement;
  286. }
  287. }
  288. // Remove label for attribute
  289. else if (this._labelElement) {
  290. this._labelElement.removeAttribute('for');
  291. }
  292. }
  293.  
  294. /**
  295. Array of additional parameters as key:value to send in addition of files.
  296. A parameter must contain a <code>name</code> key:value and optionally a <code>value</code> key:value.
  297.  
  298. @type {Array.<Object>}
  299. @default []
  300. */
  301. get parameters() {
  302. return this._parameters || [];
  303. }
  304.  
  305. set parameters(values) {
  306. // Verify that every item has a name
  307. const isValid = Array.isArray(values) && values.every((el) => el && el.name);
  308.  
  309. if (isValid) {
  310. this._parameters = values;
  311.  
  312. if (!this.async) {
  313. Array.prototype.forEach.call(this.querySelectorAll('input[type="hidden"]'), (input) => {
  314. input.parentNode.removeChild(input);
  315. });
  316.  
  317. // Add extra parameters
  318. this.parameters.forEach((param) => {
  319. const input = document.createElement('input');
  320. input.type = 'hidden';
  321. input.name = param.name;
  322. input.value = param.value;
  323.  
  324. this.appendChild(input);
  325. });
  326. }
  327. }
  328. }
  329.  
  330. /**
  331. Whether files should be uploaded asynchronously via XHR or synchronously e.g. within a
  332. <code>&lt;form&gt;</code> tag. One option excludes the other. Setting a new <code>async</code> value removes all
  333. files from the queue.
  334.  
  335. @type {Boolean}
  336. @default false
  337. @htmlattribute async
  338. @htmlattributereflected
  339. */
  340. get async() {
  341. return this._async || false;
  342. }
  343.  
  344. set async(value) {
  345. this._async = transform.booleanAttr(value);
  346. this._reflectAttribute('async', this._async);
  347.  
  348. // Sync extra parameters in case of form submission
  349. if (!this._async) {
  350. this.parameters = this.parameters;
  351. }
  352.  
  353. // Clear file selection
  354. if (this._uploadQueue) {
  355. this._clearQueue();
  356. this._clearFileInputValue();
  357. }
  358. }
  359.  
  360. /**
  361. The URL where the upload request should be sent. When used within a <code>&lt;form&gt;</code> tag to upload
  362. synchronously, the action of the form is used. If an element is clicked that has a
  363. <code>[coral-fileupload-submit]</code> attribute as well as a <code>[formaction]</code> attribute, the action of
  364. the clicked element will be used. Set this property before calling {@link Coral.FileUpload#upload} to reset the
  365. action set by a click.
  366.  
  367. @type {String}
  368. @default ""
  369. @htmlattribute action
  370. @htmlattributereflected
  371. */
  372. get action() {
  373. return this._action || '';
  374. }
  375.  
  376. set action(value) {
  377. this._action = transform.string(value);
  378. this._reflectAttribute('action', this._action);
  379.  
  380. // Reset button action as action was set explicitly
  381. this._buttonAction = null;
  382. }
  383.  
  384. /**
  385. The HTTP method to use when uploading files asynchronously. When used within a <code>&lt;form&gt;</code> tag to
  386. upload synchronously, the method of the form is used. If an element is clicked that has a
  387. <code>[coral-fileupload-submit]</code> attribute as well as a <code>[formmethod]</code> attribute, the method of
  388. the clicked element will be used. Set this property before calling {@link FileUpload#upload} to reset the
  389. method set by a click.
  390. See {@link FileUploadMethodEnum}.
  391.  
  392. @type {String}
  393. @default FileUploadMethodEnum.POST
  394. @htmlattribute method
  395. @htmlattributereflected
  396. */
  397. get method() {
  398. return this._method || method.POST;
  399. }
  400.  
  401. set method(value) {
  402. value = transform.string(value).toUpperCase();
  403. this._method = validate.enumeration(method)(value) && value || method.POST;
  404. this._reflectAttribute('method', this._method);
  405.  
  406. // Reset button method as method was set explcitly
  407. this._buttonMethod = null;
  408. }
  409.  
  410. /**
  411. Whether more than one file can be chosen at the same time to upload.
  412.  
  413. @type {Boolean}
  414. @default false
  415. @htmlattribute multiple
  416. @htmlattributereflected
  417. */
  418. get multiple() {
  419. return this._elements.input.multiple;
  420. }
  421.  
  422. set multiple(value) {
  423. this._elements.input.multiple = transform.booleanAttr(value);
  424. this._reflectAttribute('multiple', this.multiple);
  425. }
  426.  
  427. /**
  428. File size limit in bytes for one file. The value of 0 indicates unlimited, which is also the default.
  429.  
  430. @type {Number}
  431. @htmlattribute sizelimit
  432. @htmlattributereflected
  433. @default 0
  434. */
  435. get sizeLimit() {
  436. return this._sizeLimit || 0;
  437. }
  438.  
  439. set sizeLimit(value) {
  440. this._sizeLimit = transform.number(value);
  441. this._reflectAttribute('sizelimit', this._sizeLimit);
  442. }
  443.  
  444. /**
  445. MIME types allowed for uploading (proper MIME types, wildcard '*' and file extensions are supported). To specify
  446. more than one value, separate the values with a comma (e.g.
  447. <code>&lt;input accept="audio/*,video/*,image/*" /&gt;</code>.
  448.  
  449. @type {String}
  450. @default ""
  451. @htmlattribute accept
  452. @htmlattributereflected
  453. */
  454. get accept() {
  455. return this._elements.input.accept;
  456. }
  457.  
  458. set accept(value) {
  459. this._elements.input.accept = value;
  460. this._reflectAttribute('accept', this.accept);
  461. }
  462.  
  463. /**
  464. Whether the upload should start immediately after file selection.
  465.  
  466. @type {Boolean}
  467. @default false
  468. @htmlattribute autostart
  469. @htmlattributereflected
  470. */
  471. get autoStart() {
  472. return this._autoStart || false;
  473. }
  474.  
  475. set autoStart(value) {
  476. this._autoStart = transform.booleanAttr(value);
  477. this._reflectAttribute('autostart', this._autoStart);
  478. }
  479.  
  480. /**
  481. Files to be uploaded.
  482.  
  483. @readonly
  484. @default []
  485. @type {Array.<Object>}
  486. */
  487. get uploadQueue() {
  488. return this._uploadQueue;
  489. }
  490.  
  491. /** @private */
  492. _onButtonFocusIn(event) {
  493. // Get the input
  494. const input = this._elements.input;
  495.  
  496. // Get the button
  497. const button = event.matchedTarget;
  498.  
  499. // Move the input to after the button
  500. // This lets the next focused item be the correct one according to tab order
  501. button.parentNode.insertBefore(input, button.nextElementSibling);
  502.  
  503. if (event.relatedTarget !== input) {
  504. // Make sure the input gets focused on FF
  505. window.setTimeout(() => {
  506. input.focus();
  507. }, 100);
  508. }
  509. }
  510.  
  511. /** @private */
  512. _onInputFocusIn() {
  513. // Get the input
  514. const input = event.matchedTarget;
  515.  
  516. const button = this.querySelector('[coral-fileupload-select]');
  517. if (button) {
  518. // Remove from the tab order so shift+tab works
  519. button.tabIndex = -1;
  520.  
  521. // So shifting focus backwards with screen reader doesn't create a focus trap
  522. button.setAttribute('aria-hidden', true);
  523.  
  524. // Mark the button as focused
  525. button.classList.add('is-focused');
  526.  
  527. window.requestAnimationFrame(() => {
  528. if (input.classList.contains('focus-ring')) {
  529. button.classList.add('focus-ring');
  530. }
  531. });
  532. }
  533. }
  534.  
  535. /** @private */
  536. _onInputFocusOut() {
  537. // Unmark all the focused buttons
  538. const button = this.querySelector('[coral-fileupload-select].is-focused');
  539. if (button) {
  540. button.classList.remove('is-focused');
  541. button.classList.remove('focus-ring');
  542. // Wait a frame so that shifting focus backwards with screen reader doesn't create a focus trap
  543. window.requestAnimationFrame(() => {
  544. button.tabIndex = 0;
  545. // @a11y: aria-hidden is removed to prevent focus trap when navigating backwards using a screen reader's
  546. // virtual cursor
  547. button.removeAttribute('aria-hidden');
  548. });
  549. }
  550. }
  551.  
  552. /** @private */
  553. _onAbortFileClick(event) {
  554. if (!this.async) {
  555. throw new Error('Coral.FileUpload does not support aborting file(s) upload on synchronous mode.');
  556. }
  557.  
  558. // Get file to abort
  559. const fileName = event.target.getAttribute('coral-fileupload-abortfile');
  560. if (fileName) {
  561. this._abortFile(fileName);
  562. }
  563. }
  564.  
  565. /** @private */
  566. _onRemoveFileClick(event) {
  567. if (!this.async) {
  568. throw new Error('Coral.FileUpload does not support removing a file from the queue on synchronous mode.');
  569. } else {
  570. // Get file to remove
  571. const fileName = event.target.getAttribute('coral-fileupload-removefile');
  572. if (fileName) {
  573. this._clearFile(fileName);
  574. }
  575. }
  576. }
  577.  
  578. /** @private */
  579. _onUploadFileClick(event) {
  580. if (!this.async) {
  581. throw new Error('Coral.FileUpload does not support uploading a file from the queue on synchronous mode.');
  582. }
  583.  
  584. // Get file to upload
  585. const fileName = event.target.getAttribute('coral-fileupload-uploadfile');
  586. if (fileName) {
  587. this.upload(fileName);
  588. }
  589. }
  590.  
  591. /** @private */
  592. _onDragAndDrop(event) {
  593. // Set dragging classes
  594. if (event.type === 'dragenter' || event.type === 'dragover') {
  595. this._addDragClass();
  596. } else if (event.type === 'dragleave' || event.type === 'drop') {
  597. this._removeDragClass();
  598. }
  599.  
  600. this.trigger(`coral-fileupload:${event.type}`);
  601. }
  602.  
  603. /** @private */
  604. _addDragClass() {
  605. window.clearTimeout(this._removeClassTimeout);
  606. this._removeClassTimeout = window.setTimeout(this._doAddDragClass, 10);
  607. }
  608.  
  609. /** @private */
  610. _doAddDragClass() {
  611. this.classList.add('is-dragging');
  612.  
  613. const dropZone = this.querySelector('[coral-fileupload-dropzone]');
  614. if (dropZone) {
  615. dropZone.classList.add('is-dragging');
  616. }
  617.  
  618. // Put the input on top to enable file drop
  619. this._elements.input.classList.remove('is-unselectable');
  620. }
  621.  
  622. /** @private */
  623. _removeDragClass() {
  624. window.clearTimeout(this._removeClassTimeout);
  625. this._removeClassTimeout = window.setTimeout(this._doRemoveDragClass, 10);
  626. }
  627.  
  628. /** @private */
  629. _doRemoveDragClass() {
  630. this.classList.remove('is-dragging');
  631.  
  632. const dropZone = this.querySelector('[coral-fileupload-dropzone]');
  633. if (dropZone) {
  634. dropZone.classList.remove('is-dragging');
  635. }
  636.  
  637. // Disable user interaction with the input
  638. this._elements.input.classList.add('is-unselectable');
  639. }
  640.  
  641. /**
  642. Handles clicks to submit buttons
  643.  
  644. @private
  645. */
  646. _onSubmitButtonClick(event) {
  647. const target = event.matchedTarget;
  648.  
  649. // Override or reset the action/method given the button's configuration
  650. this._buttonAction = target.getAttribute('formaction');
  651.  
  652. // Make sure the method provided by the button is valid
  653. const buttonMethod = transform.string(target.getAttribute('formmethod')).toUpperCase();
  654. this._buttonMethod = validate.enumeration(method)(buttonMethod) && buttonMethod || null;
  655.  
  656. // Start the file upload
  657. this.upload();
  658. }
  659.  
  660. /**
  661. Handles changes to the input element.
  662.  
  663. @private
  664. */
  665. _onInputChange(event) {
  666. // Stop the current event
  667. event.stopPropagation();
  668.  
  669. if (this.disabled) {
  670. return;
  671. }
  672.  
  673. let files = [];
  674. const items = [];
  675.  
  676. // Retrieve files for select event
  677. if (event.target.files && event.target.files.length) {
  678. this._clearQueue();
  679. files = event.target.files;
  680.  
  681. // Verify if multiple file upload is allowed
  682. if (!this.multiple) {
  683. files = [files[0]];
  684. }
  685. }
  686. // Retrieve files for drop event
  687. else if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
  688. this._clearQueue();
  689. files = event.dataTransfer.files;
  690.  
  691. // Verify if multiple file upload is allowed
  692. if (!this.multiple) {
  693. files = [files[0]];
  694. }
  695. } else {
  696. return;
  697. }
  698.  
  699. // Initialize items
  700. for (let i = 0 ; i < files.length ; i++) {
  701. items.push(new FileUploadItem(files[i]));
  702. }
  703.  
  704. // Verify if file is allowed to be uploaded and trigger events accordingly
  705. items.forEach((item) => {
  706. // If file is not found in uploadQueue using filename
  707. if (!this._getQueueItemByFilename(item.file.name)) {
  708. // Check file size
  709. if (this.sizeLimit && item.file.size > this.sizeLimit) {
  710. this.trigger('coral-fileupload:filesizeexceeded', {item});
  711. }
  712. // Check mime type
  713. else if (this.accept && !item._isMimeTypeAllowed(this.accept)) {
  714. this.trigger('coral-fileupload:filemimetyperejected', {item});
  715. } else {
  716. // Add item to queue
  717. this._uploadQueue.push(item);
  718.  
  719. this.trigger('coral-fileupload:fileadded', {item});
  720. }
  721. }
  722. });
  723.  
  724. if (this.autoStart) {
  725. this.upload();
  726. }
  727.  
  728. // Explicitly re-emit the change event
  729. if (this._triggerChangeEvent) {
  730. this.trigger('change');
  731. }
  732.  
  733. // Clear file input once files are added to the queue to make sure next file selection will trigger a change event
  734. if (this.async) {
  735. this._clearFileInputValue();
  736. }
  737. }
  738.  
  739. /**
  740. Sets the disabled/readonly state of elements with the associated special attributes
  741.  
  742. @private
  743. */
  744. _setElementState() {
  745. Array.prototype.forEach.call(this.querySelectorAll(
  746. '[coral-fileupload-select],' +
  747. '[coral-fileupload-dropzone],' +
  748. '[coral-fileupload-submit],' +
  749. '[coral-fileupload-clear],' +
  750. '[coral-fileupload-abort],' +
  751. '[coral-fileupload-abortfile],' +
  752. '[coral-fileupload-removefile],' +
  753. '[coral-fileupload-uploadfile]'
  754. ), (item) => {
  755. item.classList.toggle('is-invalid', this.invalid);
  756. item.classList.toggle('is-disabled', this.disabled);
  757. item.classList.toggle('is-required', this.required);
  758. item.classList.toggle('is-readOnly', this.readOnly);
  759. item[this.disabled || this.readOnly ? 'setAttribute' : 'removeAttribute']('disabled', '');
  760. });
  761. }
  762.  
  763. /** @private */
  764. _clearQueue() {
  765. this._uploadQueue.slice().forEach((item) => {
  766. this._clearFile(item.file.name);
  767. });
  768. }
  769.  
  770. /**
  771. Clear file selection on the file input
  772.  
  773. @private
  774. */
  775. _clearFileInputValue() {
  776. this._elements.input.value = '';
  777. }
  778.  
  779. /**
  780. Remove a file from the upload queue.
  781.  
  782. @param {String} filename
  783. The filename of the file to remove.
  784.  
  785. @private
  786. */
  787. _clearFile(filename) {
  788. const item = this._getQueueItemByFilename(filename);
  789. if (item) {
  790. // Abort file upload
  791. this._abortFile(filename);
  792.  
  793. // Remove file from queue
  794. this._uploadQueue.splice(this._getQueueIndex(filename), 1);
  795.  
  796. this.trigger('coral-fileupload:fileremoved', {item});
  797. }
  798. }
  799.  
  800. /**
  801. Uploads a file in the queue. If an array is provided as the first argument, it is used as the parameters.
  802.  
  803. @param filename {String}
  804. The name of the file to upload.
  805.  
  806. @private
  807. */
  808. _uploadFile(filename) {
  809. const item = this._getQueueItemByFilename(filename);
  810. if (item) {
  811. this._abortFile(filename);
  812. this._ajaxUpload(item);
  813. }
  814. }
  815.  
  816. /** @private */
  817. _showFileDialog() {
  818. // Show the dialog
  819. // This ONLY works when the call stack traces back to another click event!
  820. this._elements.input.click();
  821. }
  822.  
  823. /**
  824. Abort specific file upload.
  825.  
  826. @param {String} filename
  827. The filename identifies the file to abort.
  828.  
  829. @private
  830. */
  831. _abortFile(filename) {
  832. const item = this._getQueueItemByFilename(filename);
  833. if (item && item._xhr) {
  834. item._xhr.abort();
  835. item._xhr = null;
  836. }
  837. }
  838.  
  839. /**
  840. Handles the ajax upload.
  841.  
  842. @private
  843. */
  844. _ajaxUpload(item) {
  845. // Use the action/method provided by the last button click, if provided
  846. const action = this._buttonAction || this.action;
  847. const requestMethod = this._buttonMethod ? this._buttonMethod.toUpperCase() : this.method;
  848.  
  849. // We merge the global parameters with the specific file parameters and send them all together
  850. const parameters = this.parameters.concat(item.parameters);
  851.  
  852. const formData = new FormData();
  853.  
  854. parameters.forEach((additionalParameter) => {
  855. formData.append(additionalParameter.name, additionalParameter.value);
  856. });
  857.  
  858. formData.append('_charset_', 'utf-8');
  859. formData.append(this.name, item._originalFile);
  860.  
  861. // Store the XHR on the item itself
  862. item._xhr = new XMLHttpRequest();
  863.  
  864. // Opening before being able to set response type to avoid IE11 InvalidStateError
  865. item._xhr.open(requestMethod, action);
  866.  
  867. // Reflect specific xhr properties
  868. item._xhr.timeout = item.timeout;
  869. item._xhr.responseType = item.responseType;
  870. item._xhr.withCredentials = item.withCredentials;
  871.  
  872. XHR_EVENT_NAMES.forEach((name) => {
  873. // Progress event is the only event among other ProgressEvents that can trigger multiple times.
  874. // Hence it's the only one that gives away usable progress information.
  875. const isProgressEvent = name === 'progress';
  876. (isProgressEvent ? item._xhr.upload : item._xhr).addEventListener(name, (event) => {
  877. const detail = {
  878. item: item,
  879. action: action,
  880. method: requestMethod
  881. };
  882.  
  883. if (isProgressEvent) {
  884. detail.lengthComputable = event.lengthComputable;
  885. detail.loaded = event.loaded;
  886. detail.total = event.total;
  887. }
  888.  
  889. this.trigger(`coral-fileupload:${name}`, detail);
  890. });
  891. });
  892.  
  893. item._xhr.send(formData);
  894. }
  895.  
  896. /** @private */
  897. _getLabellableElement() {
  898. return this;
  899. }
  900.  
  901. /** @private */
  902. _getQueueItemByFilename(filename) {
  903. return this._getQueueItem(this._getQueueIndex(filename));
  904. }
  905.  
  906. /** @private */
  907. _getQueueItem(index) {
  908. return index > -1 ? this._uploadQueue[index] : null;
  909. }
  910.  
  911. /** @private */
  912. _getQueueIndex(filename) {
  913. let index = -1;
  914. this._uploadQueue.some((item, i) => {
  915. if (item.file.name === filename) {
  916. index = i;
  917. return true;
  918. }
  919.  
  920. return false;
  921. });
  922. return index;
  923. }
  924.  
  925. /** @private */
  926. _getTargetChangeInput() {
  927. return this._elements.input;
  928. }
  929.  
  930. /** @ignore */
  931. _positionInputOnDropZone() {
  932. const input = this._elements.input;
  933. const dropZone = this.querySelector('[coral-fileupload-dropzone]');
  934.  
  935. if (dropZone) {
  936. const size = dropZone.getBoundingClientRect();
  937.  
  938. input.style.top = `${parseInt(dropZone.offsetTop, 10)}px`;
  939. input.style.left = `${parseInt(dropZone.offsetLeft, 10)}px`;
  940. input.style.width = `${parseInt(size.width, 10)}px`;
  941. input.style.height = `${parseInt(size.height, 10)}px`;
  942. } else {
  943. input.style.width = '0px';
  944. input.style.height = '0px';
  945. input.style.visibility = 'hidden';
  946. }
  947. }
  948.  
  949. /**
  950. Uploads the given filename, or all the files into the queue. It accepts extra parameters that are sent with the
  951. file.
  952.  
  953. @param {String} [filename]
  954. The name of the file to upload.
  955. */
  956. upload(filename) {
  957. if (!this.async) {
  958. if (typeof filename === 'string') {
  959. throw new Error('Coral.FileUpload does not support uploading a file from the queue on synchronous mode.');
  960. }
  961.  
  962. let form = this.closest('form');
  963. if (!form) {
  964. form = document.createElement('form');
  965. form.method = this.method.toLowerCase();
  966. form.enctype = 'multipart/form-data';
  967. form.action = this.action;
  968. form.hidden = true;
  969.  
  970. form.appendChild(this._elements.input);
  971.  
  972. Array.prototype.forEach.call(this.querySelectorAll('input[type="hidden"]'), (hiddenInput) => {
  973. form.appendChild(hiddenInput);
  974. });
  975.  
  976. // Make sure the form is connected before submission
  977. this.appendChild(form);
  978. }
  979.  
  980. const input = document.createElement('input');
  981. input.type = 'hidden';
  982. input.name = '_charset_';
  983. input.value = 'utf-8';
  984.  
  985. form.submit();
  986. } else if (typeof filename === 'string') {
  987. this._uploadFile(filename);
  988. } else {
  989. this._uploadQueue.forEach((item) => {
  990. this._abortFile(item.file.name);
  991. this._ajaxUpload(item);
  992. });
  993. }
  994. }
  995.  
  996. /**
  997. Remove a file or all files from the upload queue.
  998.  
  999. @param {String} [filename]
  1000. The filename of the file to remove. If a filename is not provided, all files will be removed.
  1001. */
  1002. clear(filename) {
  1003. if (!this.async) {
  1004. if (typeof filename === 'string') {
  1005. throw new Error('Coral.FileUpload does not support removing a file from the queue on synchronous mode.');
  1006. }
  1007. this._clearQueue();
  1008. this._clearFileInputValue();
  1009. } else if (typeof filename === 'string') {
  1010. this._clearFile(filename);
  1011. } else {
  1012. this._clearQueue();
  1013. }
  1014. }
  1015.  
  1016. /**
  1017. Abort upload of a given file or all files in the queue.
  1018.  
  1019. @param {String} [filename]
  1020. The filename of the file to abort. If a filename is not provided, all files will be aborted.
  1021. */
  1022. abort(filename) {
  1023. if (!this.async) {
  1024. throw new Error('Coral.FileUpload does not support aborting file(s) upload on synchronous mode.');
  1025. }
  1026.  
  1027. if (typeof filename === 'string') {
  1028. // Abort a single file
  1029. this._abortFile(filename);
  1030. } else {
  1031. // Abort all files
  1032. this._uploadQueue.forEach((item) => {
  1033. this._abortFile(item.file.name);
  1034. });
  1035. }
  1036. }
  1037.  
  1038. static get _attributePropertyMap() {
  1039. return commons.extend(super._attributePropertyMap, {
  1040. sizelimit: 'sizeLimit',
  1041. autostart: 'autoStart'
  1042. });
  1043. }
  1044.  
  1045. /** @ignore */
  1046. static get observedAttributes() {
  1047. return super.observedAttributes.concat([
  1048. 'async',
  1049. 'action',
  1050. 'method',
  1051. 'multiple',
  1052. 'sizelimit',
  1053. 'accept',
  1054. 'autostart'
  1055. ]);
  1056. }
  1057.  
  1058. /** @ignore */
  1059. render() {
  1060. super.render();
  1061.  
  1062. this.classList.add(CLASSNAME);
  1063.  
  1064. const button = this.querySelector('[coral-fileupload-select]');
  1065. if (button) {
  1066. button.id = button.id || commons.getUID();
  1067. }
  1068. // If no labelledby is specified, ensure input is at labelledby the select button
  1069. this.labelledBy = this.labelledBy;
  1070.  
  1071. // Fetch additional parameters if any
  1072. const parameters = [];
  1073. Array.prototype.forEach.call(this.querySelectorAll('input[type="hidden"]'), (input) => {
  1074. parameters.push({
  1075. name: input.name,
  1076. value: input.value
  1077. });
  1078. });
  1079. this.parameters = parameters;
  1080.  
  1081. // Remove the input if it's already there
  1082. // A fresh input is preferred to value = '' as it may not work in all browsers
  1083. const inputElement = this.querySelector('[handle="input"]');
  1084. if (inputElement) {
  1085. inputElement.parentNode.removeChild(inputElement);
  1086. }
  1087.  
  1088. // Add the input to the component
  1089. this.appendChild(this._elements.input);
  1090.  
  1091. // IE11 requires one more frame or the resize listener <object> will appear as an overlaying white box
  1092. window.requestAnimationFrame(() => {
  1093. // Handles the repositioning of the input to allow dropping files
  1094. commons.addResizeListener(this, this._positionInputOnDropZone);
  1095. });
  1096. }
  1097. });
  1098.  
  1099. export default FileUpload;