@postnord/web-components
Version:
PostNord Web Components
931 lines (930 loc) • 36.7 kB
JavaScript
/*!
* 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