UNPKG

@postnord/web-components

Version:
1,021 lines (1,020 loc) 39.9 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()}`; idHelpertext = `${this.id}-helpertext`; idAccepttext = `${this.id}-accept`; fileInputElement; invalid = false; maxSizeInBytes = 0; rippleClass = '.pn-file-upload-dropzone'; hostElement; files = []; isHovering = false; /** The default label is "Click or drag a file here". @since v7.1.0 */ label; /** * The helpertext defaults to "Supported formats {x} and max filesize {y}", * if there is a value set for the prop `accept`. * @since v7.1.0 **/ helpertext; /** Manually set the language. @since v7.1.0 */ language = undefined; /** * Set a HTML name. * @since v7.1.0 * @category Native attributes */ name; /** Set which file types the input accepts. Ex: .docs,.xml @category Native attributes */ accept; /** * Use the capture mode. * @since v7.1.0 * @category Native attributes */ capture; /** Allow multiple files to be selected. Will be overwritten to true, if `limit` is above 1. @category Native attributes */ multiple; /** Set the input as required. @category Native attributes */ required = false; /** Disable the input. @category Native attributes */ disabled = false; /** * Set maximum amount of files to be selected. * Any value above 1 will force the `multiple` to be `true`. * @category Features **/ limit = 1; /** Set the maximum upload size. @category Features */ maxSize = ''; /** * Always hide the progress bar, even when upload has begun. * @since v7.1.0 * @category Features */ hideProgress = false; /** Set a custom ID for the file upload. @since v7.25.0 @category HTML attributes */ pnId = this.id; handleId() { this.idHelpertext = `${this.getId()}-helpertext`; this.idAccepttext = `${this.getId()}-accept`; } 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() { if (this.language === undefined) await awaitTopbar(this.hostElement); } getId() { return this.pnId || this.id; } /** 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, this.rippleClass); 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'; } 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.showAcceptText() && this.idAccepttext, this.helpertext && this.idHelpertext].filter(Boolean); if (!list.length) return null; return list.join(' '); } showAcceptText() { return !!this.accept || !!this.maxSize || this.limit > 1; } getAcceptText() { const list = []; if (this.accept) list.push(`${this.translate('ACCEPTEDFORMATS')}: ${this.accept}`); if (this.maxSize) { const maxSizeText = this.translate('MAXFILESIZE'); list.push(`${list.length ? maxSizeText.toLowerCase() : maxSizeText}: ${this.maxSize}`); } if (this.limit > 1) { const limitText = this.translate('LIMIT'); list.push(`${list.length ? limitText.toLowerCase() : limitText}: ${this.limit}`); } return list.join(', '); } render() { return (h(Host, { key: 'bda8680e48b7537469f7a348134552cc40201ff3' }, h("div", { key: '6e661705703b2b2d50a794619f1ed4ad123a05e0', class: "pn-file-upload" }, h("div", { key: 'd2d17b3cd8cd15564a63a82d544af8c06b9e6fb5', class: "pn-file-upload-container" }, h("input", { key: '0e8801c1478852c946e8f31d1dd6dde1ee5b4441', id: this.getId(), 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(), "aria-invalid": this.invalid?.toString(), onClick: e => ripple(e, this.hostElement, this.rippleClass), 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: '802d986996b9cdc9f577906631dfa1d3eed8f8e4', class: "pn-file-upload-dropzone", "data-hover": this.isHovering, "data-invalid": this.invalid }, h("pn-icon", { key: '1c4e0120ebdf2f06401f130549fb9e8c9e97c920', class: "pn-file-upload-icon-element", icon: upload, color: "blue700" }), h("label", { key: '8f5275d7e7591aee9167cf3fc858b8ec22070d4f', class: "pn-file-upload-title", htmlFor: this.getId() }, this.label || this.translate('CLICKORDRAG')), this.helpertext && (h("p", { key: 'f0a1bc0aaece7c470a756c46aefee84f7059b548', class: "pn-file-upload-subtitle", id: this.idHelpertext }, this.helpertext)), this.showAcceptText() && (h("p", { key: '4d8d595fe7e10bdf5cb01c4b7b448b3b11458c15', class: "pn-file-upload-subtitle", id: this.idAccepttext }, this.getAcceptText())))), !!this.files.length && (h("ul", { key: '55bb12c9e21aceae0bffd49b400f1902f7edb564', class: "pn-file-upload-files" }, this.files.map((file) => (h("li", { class: "pn-file-upload-files-item", key: file.uuid }, h("div", { class: "pn-file-upload-file", "data-error": !!file.error }, h("div", { class: "pn-file-upload-file-title" }, h("pn-icon", { icon: this.getFileIcon(file), color: this.getFileColor(file) }), this.hideProgress || file.progress === 0 ? (h("div", { class: "pn-file-upload-file-name" }, 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: "pn-file-upload-file-error" }, file.errorMessage || file.error.message)) : null))))), h("slot", { key: 'd5e2793ee8d72550129b38825dde9d25768d8528' })))); } 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": [{ "name": "since", "text": "v7.1.0" }], "text": "The default label is \"Click or drag a file here\"." }, "getter": false, "setter": false, "reflect": false, "attribute": "label" }, "helpertext": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "since", "text": "v7.1.0" }], "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, "reflect": false, "attribute": "helpertext" }, "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", "referenceLocation": "PnLanguages" } } }, "required": false, "optional": true, "docs": { "tags": [{ "name": "since", "text": "v7.1.0" }], "text": "Manually set the language." }, "getter": false, "setter": false, "reflect": false, "attribute": "language", "defaultValue": "undefined" }, "name": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "since", "text": "v7.1.0" }, { "name": "category", "text": "Native attributes" }], "text": "Set a HTML name." }, "getter": false, "setter": false, "reflect": false, "attribute": "name" }, "accept": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Native attributes" }], "text": "Set which file types the input accepts. Ex: .docs,.xml" }, "getter": false, "setter": false, "reflect": false, "attribute": "accept" }, "capture": { "type": "string", "mutable": false, "complexType": { "original": "'' | 'user' | 'environment'", "resolved": "\"\" | \"environment\" | \"user\"", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "since", "text": "v7.1.0" }, { "name": "category", "text": "Native attributes" }], "text": "Use the capture mode." }, "getter": false, "setter": false, "reflect": false, "attribute": "capture" }, "multiple": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Native attributes" }], "text": "Allow multiple files to be selected. Will be overwritten to true, if `limit` is above 1." }, "getter": false, "setter": false, "reflect": false, "attribute": "multiple" }, "required": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Native attributes" }], "text": "Set the input as required." }, "getter": false, "setter": false, "reflect": false, "attribute": "required", "defaultValue": "false" }, "disabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Native attributes" }], "text": "Disable the input." }, "getter": false, "setter": false, "reflect": false, "attribute": "disabled", "defaultValue": "false" }, "limit": { "type": "number", "mutable": false, "complexType": { "original": "number", "resolved": "number", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Set maximum amount of files to be selected.\nAny value above 1 will force the `multiple` to be `true`." }, "getter": false, "setter": false, "reflect": false, "attribute": "limit", "defaultValue": "1" }, "maxSize": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Set the maximum upload size." }, "getter": false, "setter": false, "reflect": false, "attribute": "max-size", "defaultValue": "''" }, "hideProgress": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "since", "text": "v7.1.0" }, { "name": "category", "text": "Features" }], "text": "Always hide the progress bar, even when upload has begun." }, "getter": false, "setter": false, "reflect": false, "attribute": "hide-progress", "defaultValue": "false" }, "pnId": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "since", "text": "v7.25.0" }, { "name": "category", "text": "HTML attributes" }], "text": "Set a custom ID for the file upload." }, "getter": false, "setter": false, "reflect": false, "attribute": "pn-id", "defaultValue": "this.id" } }; } 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", "referenceLocation": "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: HTMLElement }", "resolved": "{ files: PnUploadFileItem[]; element: HTMLElement; }", "references": { "PnUploadFileItem": { "location": "import", "path": "@/globals/types", "id": "src/globals/types.ts::PnUploadFileItem", "referenceLocation": "PnUploadFileItem" }, "HTMLElement": { "location": "global", "id": "global::HTMLElement" } } } }, { "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", "referenceLocation": "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", "referenceLocation": "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", "referenceLocation": "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", "referenceLocation": "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", "referenceLocation": "PnUploadFileItem" } }, "return": "Promise<void>" }, "docs": { "text": "Update an existing file.", "tags": [] } } }; } static get elementRef() { return "hostElement"; } static get watchers() { return [{ "propName": "pnId", "methodName": "handleId", "handlerOptions": { "immediate": true } }, { "propName": "limit", "methodName": "handleLimit", "handlerOptions": { "immediate": true } }, { "propName": "maxSize", "methodName": "handleMaxBytes", "handlerOptions": { "immediate": true } }]; } }