UNPKG

@postnord/web-components

Version:

PostNord Web Components

931 lines (930 loc) 36.7 kB
/*! * Built with Stencil * By PostNord. */ import { Host, h } from "@stencil/core"; import { getBytesFromHumanReadableFileSize, uuidv4, en, ripple, awaitTopbar } from "../../../globals/helpers"; import { PnFileUploadError } from "./pn-file-upload-error"; import { trash_can, upload, alert_exclamation_circle, attachment, check } from "pn-design-assets/pn-assets/icons.js"; import { translations } from "./translation"; /** * Users can drop files directly into the upload area of the component * or simply click on it to open their native file explorer. * * @nativeChange Use the `change` event to listen when the input receives files. * * @nativeCancel The `cancel` event lets you know the user canceled their selection. * Either via clicking on the "Cancel" button or pressing `ESC`. * */ export class PnFileUpload { id = `pn-file-upload-${uuidv4()}`; idFileUpload = `${this.id}-label`; idHelpertext = `${this.id}-helpertext`; idAccepttext = `${this.id}-accept`; fileInputElement; invalid = false; maxSizeInBytes = 0; hostElement; files = []; isHovering = false; /** The default label is "Click or drag a file here". */ label; /** * The helpertext defaults to "Supported formats {x} and max filesize {y}", * if there is a value set for the prop `accept`. **/ helpertext; /** Manually set the language. @category Features */ language = null; /** Always hide the progress bar, even when upload has begun. @category Features */ hideProgress = false; /** Set a HTML name. @category HTML attributes */ name; /** Set which file types the input accepts. Ex: .docs,.xml @category HTML attributes */ accept; /** Set the maximum upload size. @category HTML attributes */ maxSize = ''; /** Use the capture mode. @category HTML attributes */ capture; /** * Set maximum amount of files to be selected. * Any value above 1 will force the `multiple` to be `true`. * @category HTML attributes **/ limit = 1; /** Allow multiple files to be selected. Will be overwritten to true, if `limit` is above 1. @category HTML attributes */ multiple; /** Set the input as required. @category HTML attributes */ required = false; /** Disable the input. @category HTML attributes */ disabled = false; handleLimit() { this.multiple = this.limit > 1; } handleMaxBytes() { this.maxSizeInBytes = getBytesFromHumanReadableFileSize(this.maxSize); } /** This event is emitted when you add files. Either via drag and drop or using the file explorer. */ filesAdded; /** Emitted everytime an update is made to one of the files. */ update; /** If any error occurs, such as: invalid file type, size, limit or error during upload. */ fileuploaderror; /** Emitted everytime you select a valid file. It's a good idea to use this event in order to make use of the built in validation features. */ valid; /** Emitted once the file upload has begun. */ uploadFile; /** Emitted when an upload has been cancelled before completion. */ uploadCancelled; /** Emitted once the upload is finished. */ uploadCompleted; async componentWillLoad() { this.handleLimit(); this.handleMaxBytes(); if (this.language === null) await awaitTopbar(this.hostElement); } /** Start the upload of all selected files (excludes invalid files). */ async startUpload() { if (!this.files.length) return; this.files = this.files.map((file, index) => { if (file.status === 'queue') { file.status = 'start'; this.uploadFile.emit({ file: file, index: index, uploader: this, }); } return file; }); const promises = this.files.map(file => file.xhrPromise().catch(() => { })); return Promise.all(promises).then(data => { const added = data.filter(item => item); this.uploadCompleted.emit(added); }); } /** Clear the selected files, state and input value. */ async clearUpload() { this.files = []; this.fileInputElement.value = ''; this.invalid = false; this.filesAdded.emit([]); } /** Remove the selected file. */ async removeFile(targetFile) { let valid = true; const files = []; for (const fileIndex in this.files) { const file = this.files[fileIndex]; if (!file.filename) { continue; } // add to files if the uuid doesn't match if (file.uuid !== targetFile.uuid) { try { this._validateFileSize(file); this._validateFileType(file); valid = true; } catch (error) { valid = false; file.error = error; file.progress = 0; file.status = 'error'; } files.push(file); } else if (file.status === 'start') { file.xhr.abort(); this.uploadCancelled.emit(file); } } this.filesAdded.emit(files); this.files = files; this.invalid = !valid || !this._isNotGreaterThanLimit(); this._checkValidity(); this._emitUpdateEvent(); } /** Update an existing file. */ async updateFile(file, index = null) { let files = this.files; // find the index if not defined if (index === null) { for (const fIndex in files) { const fFile = files[fIndex]; if (fFile.uuid === file.uuid) { index = fIndex; break; } } } if (files[index]) { if (file.progress >= 100) { file.progress = 100; file.status = 'completed'; } else if (file.progress < 0) { file.status = 'start'; file.progress = 0; } if (file.error) { file.progress = 0; file.status = 'error'; file.errorMessage = file.errorMessage || 'There was an error uploading your file, please try again'; } files[index] = file; } this.files = [...files]; } async _addFilesFromFileList(fileList) { if (this.disabled || this.invalid) { return; } const files = Array.from(fileList); const promises = []; if (this.multiple) { files.forEach(file => promises.push(this._addFile(file))); } else { const file = files[0]; if (this.limit === 1) { this.files = []; promises.push(this._addFile(file)); } else { promises.push(this._addFile(file)); } } return Promise.all(promises).then(() => { this.filesAdded.emit(this.files); this._isNotGreaterThanLimit(); this._checkValidity(); this._emitUpdateEvent(); }); } async _onAddFile() { const files = this.fileInputElement.files; return await this._addFilesFromFileList(files); } _onDragOverFile(event) { event.preventDefault(); event.stopPropagation(); this.isHovering = true; } _onDragLeaveFile(event) { event.preventDefault(); event.stopPropagation(); this.isHovering = false; } _onDropFile(event) { event.preventDefault(); event.stopPropagation(); this.isHovering = false; ripple(event, this.hostElement, '.pn-dropzone-inner'); this._addFilesFromFileList(event.dataTransfer.files); } _validateFileType(file) { const accepts = this.accept ? this.accept.split(',') : []; if (accepts.length) { let valid = false; for (const accept of accepts) { const type = accept.trim().toLowerCase(); const isMimeType = /^\w+\/[a-z\.\-]+$/i; const extension = file.filename.match(/\.\w{3,4}($|\?)/)?.[0]?.toLowerCase(); if ((isMimeType.test(type) && file.contentType === type) || extension === type) { valid = true; break; } } if (!valid) { const error = new PnFileUploadError(`Invalid file type.`, this.hostElement, 'INVALID_FILE_TYPE', file); this.invalid = true; this.fileuploaderror.emit(error); throw error; } } } _validateFileSize(file) { if (this.maxSizeInBytes > 0 && file.filesize > this.maxSizeInBytes) { const error = new PnFileUploadError(`You can only add up to ${this.maxSize}.`, this.hostElement, 'MAX_FILE_SIZE', file); this.invalid = true; this.fileuploaderror.emit(error); throw error; } } _validateLimit() { if (this.limit && this.files.length > this.limit) { const error = new PnFileUploadError(`You can only add up to ${this.limit} files.`, this.hostElement, 'MAX_LIMIT', this.files); this.invalid = true; this.fileuploaderror.emit(error); throw error; } } _isNotGreaterThanLimit() { try { this._validateLimit(); return true; } catch (error) { return false; } } _checkValidity() { if (!this.invalid) { this.valid.emit(this); } return !this.invalid; } _createBase64FromFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = error => reject(error); reader.onabort = () => reject('ABORTED'); reader.readAsDataURL(file); }); } async _addFile(file) { return this._createBase64FromFile(file).then(data => { const split = data.split(','); const base64 = split[1]; const contentType = split[0].split(':')[1].split(';')[0]; const request = new XMLHttpRequest(); const uploadFile = { uuid: uuidv4(), filename: file.name, filesize: file.size, progress: 0, status: 'queue', base64, contentType, error: null, errorMessage: '', file, xhr: request, }; try { this._validateFileSize(uploadFile); this._validateFileType(uploadFile); } catch (error) { uploadFile.error = error; uploadFile.progress = 0; uploadFile.status = 'error'; } uploadFile.xhrPromise = () => { return new Promise((resolve, reject) => { request.responseType = 'json'; request.addEventListener('progress', event => { if (event.lengthComputable) { uploadFile.progress = (event.loaded / event.total) * 100; this.updateFile(uploadFile); } else { uploadFile.progress = 100; this.updateFile(uploadFile); } }); request.addEventListener('load', (response) => resolve(response.target.response)); request.addEventListener('error', error => { uploadFile.error = error; uploadFile.errorMessage = 'There was an error uploading your file, please try again'; this.updateFile(uploadFile); const errorEvent = new PnFileUploadError('There was an error uploading your file, please try again', this.hostElement, 'UPLOAD_SERVER_ERROR', uploadFile); this.fileuploaderror.emit(errorEvent); reject(error); }); }); }; this.files = [...this.files, uploadFile]; return uploadFile; }); } _emitUpdateEvent() { this.update.emit({ files: this.files, element: this.hostElement, }); } translate(prop) { return translations?.[prop]?.[this.language || en] || prop; } isLoading(file) { return file.status === 'start'; } getFileItemClass = (file) => { let itemClass = 'files-list-item'; if (file.error) { itemClass += ' files-list-item-error'; } return itemClass; }; getFileIcon(file) { if (file.error) return alert_exclamation_circle; if (file.progress === 100) return check; return attachment; } getFileColor(file) { if (file.error) return 'warning'; if (file.progress === 100) return 'green700'; return 'gray900'; } describedBy() { const list = [this.accept && this.idAccepttext, this.helpertext && this.idHelpertext].filter(Boolean); if (!list.length) return null; return list.join(','); } render() { return (h(Host, { key: '45b4b2297684ce76ecf94568eeecc06ae978e22a' }, h("div", { key: 'fff6b988f77142534aa523cef56d74e5b5d980dd', class: "pn-file-upload-container" }, h("div", { key: 'c7b67fd0f807e57935db32e0b48b8a5ef5dc2417', class: "pn-dropzone-container" }, h("input", { key: 'd93379b84d60389014e6c1d7e86767cb16353ab9', id: this.idFileUpload, class: "pn-file-upload-input", type: "file", name: this.name, multiple: this.multiple, accept: this.accept, capture: this.capture, disabled: this.disabled, required: this.required, "aria-describedby": this.describedBy(), onClick: e => ripple(e, this.hostElement, '.pn-dropzone-inner'), onChange: () => this._onAddFile(), onDragOver: e => this._onDragOverFile(e), onDragLeave: e => this._onDragLeaveFile(e), onDrop: e => this._onDropFile(e), ref: el => (this.fileInputElement = el) }), h("div", { key: '50c1d0cd6c72749868b2a338e380dc89cca6b924', class: "pn-dropzone-inner", "data-hover": this.isHovering, "data-invalid": this.invalid }, h("pn-icon", { key: 'e79c9a3d153d4db2bced218da34aac598e71b153', class: "pn-file-upload-icon-element", icon: upload, color: "blue700" }), h("label", { key: 'a3d8268d1c202931dc86ed476f1373a3a6002613', htmlFor: this.idFileUpload, class: "pn-file-upload-title" }, this.label || this.translate('CLICKORDRAG')), this.helpertext && (h("p", { key: 'f4b8968c2289f417253428412b5bfbad4bc60e86', id: this.idHelpertext, class: "pn-file-upload-subtitle" }, this.helpertext)), this.accept && (h("p", { key: 'f846a7671fdc9146c75adf3c2f59e6a08350771f', id: this.idAccepttext, class: "pn-file-upload-subtitle" }, this.translate('ACCEPTEDFORMATS'), ": ", this.accept, this.maxSize && (h("span", { key: 'c83df98318dbd8836c5695d3a1e37890e804e372' }, ' ', "(", this.translate('MAXFILESIZE'), ": ", this.maxSize, ")")))))), !!this.files.length && (h("ul", { key: 'eedb7ff0f6ec5ac02814d8251d61049c53989a8e', class: "files-list" }, this.files.map((file) => (h("li", { class: "files-list-item-container", key: file.uuid }, h("div", { class: this.getFileItemClass(file) }, h("div", { class: "file-list-item-title" }, h("pn-icon", { icon: this.getFileIcon(file), color: this.getFileColor(file) }), this.hideProgress || file.progress === 0 ? (h("div", { class: "filename" }, file.filename)) : (h("pn-progress-bar", { label: file.filename, progress: file.progress, error: file.error && file.errorMessage }))), h("pn-button", { small: true, variant: "borderless", arialabel: `${this.translate('REMOVE_FILE')} ${file.filename}`, appearance: file.error ? 'warning' : 'light', icon: trash_can, iconOnly: true, tooltip: this.translate('REMOVE_FILE'), loading: this.isLoading(file), onClick: () => this.removeFile(file) })), file.error ? (h("div", { class: "files-list-item-text-error" }, file.errorMessage || file.error.message)) : null))))), h("slot", { key: '71b0feee09a4a9d5a0f81f9e804cabf9166a9ee8' })))); } static get is() { return "pn-file-upload"; } static get originalStyleUrls() { return { "$": ["pn-file-upload.scss"] }; } static get styleUrls() { return { "$": ["pn-file-upload.css"] }; } static get properties() { return { "label": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The default label is \"Click or drag a file here\"." }, "getter": false, "setter": false, "attribute": "label", "reflect": false }, "helpertext": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The helpertext defaults to \"Supported formats {x} and max filesize {y}\",\nif there is a value set for the prop `accept`." }, "getter": false, "setter": false, "attribute": "helpertext", "reflect": false }, "language": { "type": "string", "mutable": false, "complexType": { "original": "PnLanguages", "resolved": "\"\" | \"da\" | \"en\" | \"fi\" | \"no\" | \"sv\"", "references": { "PnLanguages": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnLanguages" } } }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Manually set the language." }, "getter": false, "setter": false, "attribute": "language", "reflect": false, "defaultValue": "null" }, "hideProgress": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Always hide the progress bar, even when upload has begun." }, "getter": false, "setter": false, "attribute": "hide-progress", "reflect": false, "defaultValue": "false" }, "name": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Set a HTML name." }, "getter": false, "setter": false, "attribute": "name", "reflect": false }, "accept": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Set which file types the input accepts. Ex: .docs,.xml" }, "getter": false, "setter": false, "attribute": "accept", "reflect": false }, "maxSize": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Set the maximum upload size." }, "getter": false, "setter": false, "attribute": "max-size", "reflect": false, "defaultValue": "''" }, "capture": { "type": "string", "mutable": false, "complexType": { "original": "'' | 'user' | 'environment'", "resolved": "\"\" | \"environment\" | \"user\"", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Use the capture mode." }, "getter": false, "setter": false, "attribute": "capture", "reflect": false }, "limit": { "type": "number", "mutable": false, "complexType": { "original": "number", "resolved": "number", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Set maximum amount of files to be selected.\nAny value above 1 will force the `multiple` to be `true`." }, "getter": false, "setter": false, "attribute": "limit", "reflect": false, "defaultValue": "1" }, "multiple": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Allow multiple files to be selected. Will be overwritten to true, if `limit` is above 1." }, "getter": false, "setter": false, "attribute": "multiple", "reflect": false }, "required": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Set the input as required." }, "getter": false, "setter": false, "attribute": "required", "reflect": false, "defaultValue": "false" }, "disabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "HTML attributes" }], "text": "Disable the input." }, "getter": false, "setter": false, "attribute": "disabled", "reflect": false, "defaultValue": "false" } }; } static get states() { return { "files": {}, "isHovering": {} }; } static get events() { return [{ "method": "filesAdded", "name": "filesAdded", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "This event is emitted when you add files. Either via drag and drop or using the file explorer." }, "complexType": { "original": "PnUploadFileItem[]", "resolved": "PnUploadFileItem[]", "references": { "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem" } } } }, { "method": "update", "name": "update", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted everytime an update is made to one of the files." }, "complexType": { "original": "{ files: PnUploadFileItem[]; element: HTMLPnFileUploadElement }", "resolved": "{ files: PnUploadFileItem[]; element: HTMLPnFileUploadElement; }", "references": { "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem" }, "HTMLPnFileUploadElement": { "location": "global", "id": "global::HTMLPnFileUploadElement" } } } }, { "method": "fileuploaderror", "name": "fileuploaderror", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "If any error occurs, such as: invalid file type, size, limit or error during upload." }, "complexType": { "original": "Error", "resolved": "Error", "references": { "Error": { "location": "global", "id": "global::Error" } } } }, { "method": "valid", "name": "valid", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted everytime you select a valid file. It's a good idea to use this event in order to make use of the built in validation features." }, "complexType": { "original": "this", "resolved": "this", "references": {} } }, { "method": "uploadFile", "name": "uploadFile", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted once the file upload has begun." }, "complexType": { "original": "{\n file: PnUploadFileItem;\n index: number;\n uploader: any;\n }", "resolved": "{ file: PnUploadFileItem; index: number; uploader: any; }", "references": { "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem" } } } }, { "method": "uploadCancelled", "name": "uploadCancelled", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when an upload has been cancelled before completion." }, "complexType": { "original": "PnUploadFileItem", "resolved": "PnUploadFileItem", "references": { "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem" } } } }, { "method": "uploadCompleted", "name": "uploadCompleted", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted once the upload is finished." }, "complexType": { "original": "PnUploadFileItem[]", "resolved": "PnUploadFileItem[]", "references": { "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem" } } } }]; } static get methods() { return { "startUpload": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "Start the upload of all selected files (excludes invalid files).", "tags": [] } }, "clearUpload": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "Clear the selected files, state and input value.", "tags": [] } }, "removeFile": { "complexType": { "signature": "(targetFile: PnUploadFileItem) => Promise<void>", "parameters": [{ "name": "targetFile", "type": "PnUploadFileItem", "docs": "" }], "references": { "Promise": { "location": "global", "id": "global::Promise" }, "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem" } }, "return": "Promise<void>" }, "docs": { "text": "Remove the selected file.", "tags": [] } }, "updateFile": { "complexType": { "signature": "(file: PnUploadFileItem, index?: any) => Promise<void>", "parameters": [{ "name": "file", "type": "PnUploadFileItem", "docs": "" }, { "name": "index", "type": "any", "docs": "" }], "references": { "Promise": { "location": "global", "id": "global::Promise" }, "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem" } }, "return": "Promise<void>" }, "docs": { "text": "Update an existing file.", "tags": [] } } }; } static get elementRef() { return "hostElement"; } static get watchers() { return [{ "propName": "limit", "methodName": "handleLimit" }, { "propName": "maxSize", "methodName": "handleMaxBytes" }]; } } //# sourceMappingURL=pn-file-upload.js.map