UNPKG

@limetech/lime-elements

Version:
555 lines (554 loc) • 18.3 kB
import { h, Host, } from '@stencil/core'; import { isTypeAccepted } from '../../util/files'; import { getIconName } from '../icon/get-icon-props'; import translate from '../../global/translations'; import { createRandomString } from '../../util/random-string'; import { resizeImage } from '../../util/image-resize'; /** * This component displays a profile picture, while allowing the user * to change it via a file input or drag-and-drop. * * It supports client-side image resizing and conversion, * as well as a simple lazy-loading mechanism. * * @exampleComponent limel-example-profile-picture-basic * @exampleComponent limel-example-profile-picture-helper-text * @exampleComponent limel-example-profile-picture-icon * @exampleComponent limel-example-profile-picture-with-value * @exampleComponent limel-example-profile-picture-loading * @exampleComponent limel-example-profile-picture-image-fit * @exampleComponent limel-example-profile-picture-composite * @exampleComponent limel-example-profile-picture-resize-contain * @exampleComponent limel-example-profile-picture-resize-cover * @exampleComponent limel-example-profile-picture-resize-fallback * @exampleComponent limel-example-profile-picture-styling * @beta */ export class ProfilePicture { constructor() { this.removeButtonId = createRandomString(); this.browseButtonId = createRandomString(); this.renderHelperText = () => { if (!this.helperText) { return; } return (h("limel-tooltip", { elementId: this.browseButtonId, label: this.helperText })); }; this.handleNewFiles = async (event) => { var _a, _b; event.stopPropagation(); if (this.disabled) { return; } const file = (_a = event.detail) === null || _a === void 0 ? void 0 : _a[0]; if (!file) { return; } if (!isTypeAccepted(file, this.accept)) { this.filesRejected.emit([file]); return; } this.revokeObjectUrl(); this.imageError = false; let out = file; // Optional client-side resize if (this.resize && file.fileContent instanceof File) { try { const processed = await resizeImage(file.fileContent, Object.assign(Object.assign({}, this.resize), { fit: (_b = this.resize.fit) !== null && _b !== void 0 ? _b : this.imageFit })); out = Object.assign(Object.assign({}, file), { filename: processed.name, size: processed.size, contentType: processed.type, fileContent: processed }); } catch (_c) { // Fall back to original file if resize fails out = file; } } // Create an object URL for immediate preview if no href present if (!out.href && out.fileContent instanceof File) { this.objectUrl = URL.createObjectURL(out.fileContent); } this.change.emit(out); }; this.handleRejectedFiles = (event) => { event.stopPropagation(); this.filesRejected.emit(event.detail); }; this.handleClear = (event) => { event.stopPropagation(); this.revokeObjectUrl(); this.imageError = false; this.change.emit(undefined); }; this.onImageError = () => { this.imageError = true; }; this.openPopover = (event) => { event.stopPropagation(); this.isErrorMessagePopoverOpen = true; }; this.onPopoverClose = (event) => { event.stopPropagation(); this.isErrorMessagePopoverOpen = false; }; this.getTranslation = (key) => { return translate.get(key, this.language); }; this.language = 'en'; this.label = undefined; this.icon = 'user'; this.helperText = undefined; this.disabled = false; this.readonly = false; this.required = false; this.invalid = false; this.loading = false; this.value = undefined; this.imageFit = 'cover'; this.accept = 'image/jpeg,image/png,image/heic,.jpg,.jpeg,.png,.heic'; this.resize = undefined; this.objectUrl = undefined; this.imageError = false; this.isErrorMessagePopoverOpen = false; } disconnectedCallback() { this.revokeObjectUrl(); } handleValueChange() { // Clear previously created object URL when value changes this.revokeObjectUrl(); this.imageError = false; // If a new File without href is provided, create an object URL for preview const currentValue = this.value; if (currentValue && typeof currentValue !== 'string' && !currentValue.href && currentValue.fileContent instanceof File) { this.objectUrl = URL.createObjectURL(currentValue.fileContent); } } render() { const hostClassNames = { 'has-image-error': this.imageError, }; if (this.readonly) { return h(Host, { class: hostClassNames }, this.renderAvatar()); } return (h(Host, { class: hostClassNames }, h("limel-file-dropzone", { disabled: this.disabled, accept: this.accept, onFilesSelected: this.handleNewFiles, onFilesRejected: this.handleRejectedFiles }, h("limel-file-input", { accept: this.accept, disabled: this.disabled, "aria-required": this.required ? 'true' : undefined, "aria-invalid": this.invalid ? 'true' : undefined }, this.renderBrowseButton())), this.renderClearButton(), this.renderSpinner(), this.renderErrorMessage(), this.renderHelperText())); } get hasValue() { if (typeof this.value === 'string') { return !!this.value; } if (this.value && (this.value.href || this.value.fileContent)) { return true; } return !!this.objectUrl; } renderBrowseButton() { return (h("button", { id: this.browseButtonId, type: "button", class: "avatar", disabled: this.disabled, "aria-label": this.label, "aria-busy": this.loading ? 'true' : 'false', "aria-live": "polite" }, this.renderAvatar())); } renderAvatar() { const src = this.getImageSrc(); if (src) { return (h("img", { src: src, alt: "", style: { '--limel-profile-picture-object-fit': this.imageFit, }, loading: "lazy", onError: this.onImageError })); } return this.renderIcon(); } renderIcon() { var _a, _b; const icon = getIconName(this.icon); return (h("limel-icon", { name: icon, style: { color: `${(_a = this.icon) === null || _a === void 0 ? void 0 : _a.color}`, 'background-color': `${(_b = this.icon) === null || _b === void 0 ? void 0 : _b.backgroundColor}`, } })); } renderClearButton() { if (!this.hasValue || this.disabled) { return; } return [ h("button", { class: "remove", type: "button", id: this.removeButtonId, onClick: this.handleClear }), h("limel-tooltip", { label: this.getTranslation('profile-picture.remove'), elementId: this.removeButtonId }), ]; } renderSpinner() { if (!this.loading) { return; } return h("limel-spinner", null); } // Collects derived flags used for deciding whether to show the unsupported preview message getUnsupportedPreviewContext() { const currentValue = this.value; const hasNoSrc = !this.getImageSrc(); const hasLocalFile = !!(currentValue && typeof currentValue !== 'string' && currentValue.fileContent instanceof File && !currentValue.href); const isResizeConfigured = !!this.resize; return { hasNoSrc, hasLocalFile, isResizeConfigured }; } shouldShowErrorMessage() { const { hasNoSrc, hasLocalFile, isResizeConfigured } = this.getUnsupportedPreviewContext(); return ((hasNoSrc || this.imageError) && hasLocalFile && isResizeConfigured); } // Shows a non-intrusive note when there is a File without href and no object URL, which // can happen if the browser failed to decode the source (e.g., HEIC in Chromium). renderErrorMessage() { if (!this.shouldShowErrorMessage()) { return; } const errorIcon = { name: 'error', color: 'rgb(var(--color-orange-dark))', }; const errorMessageStyles = { maxWidth: '20rem', borderRadius: '0.75rem', }; return (h("limel-popover", { open: this.isErrorMessagePopoverOpen, onClick: this.openPopover, onClose: this.onPopoverClose }, h("limel-icon-button", { slot: "trigger", elevated: true, icon: errorIcon, "aria-live": "polite", label: this.getTranslation('profile-picture.unsupported-preview.title') }), h("limel-callout", { type: "warning", style: errorMessageStyles, heading: this.getTranslation('profile-picture.unsupported-preview.title') }, this.getTranslation('profile-picture.unsupported-preview.description')))); } getImageSrc() { if (!this.value) { return this.objectUrl; // Could be set from last selection before parent consumes } if (typeof this.value === 'string') { return this.value; } if (this.value.href) { return this.value.href; } if (this.value.fileContent instanceof File) { return this.objectUrl; } return undefined; } revokeObjectUrl() { if (this.objectUrl) { URL.revokeObjectURL(this.objectUrl); this.objectUrl = undefined; } } static get is() { return "limel-profile-picture"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["profile-picture.scss"] }; } static get styleUrls() { return { "$": ["profile-picture.css"] }; } static get properties() { return { "language": { "type": "string", "mutable": false, "complexType": { "original": "Languages", "resolved": "\"da\" | \"de\" | \"en\" | \"fi\" | \"fr\" | \"nb\" | \"nl\" | \"no\" | \"sv\"", "references": { "Languages": { "location": "import", "path": "../date-picker/date.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Defines the language for translations.\nWill translate the translatable strings on the components." }, "attribute": "language", "reflect": true, "defaultValue": "'en'" }, "label": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Accessible label for the the browse button." }, "attribute": "label", "reflect": true }, "icon": { "type": "string", "mutable": false, "complexType": { "original": "string | Icon", "resolved": "Icon | string", "references": { "Icon": { "location": "import", "path": "../../global/shared-types/icon.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Placeholder icon of the component, displayed when no image is present." }, "attribute": "icon", "reflect": false, "defaultValue": "'user'" }, "helperText": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Helper text shown as a tooltip on hover or focus." }, "attribute": "helper-text", "reflect": false }, "disabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Disables user interaction.\nPrevents uploading new pictures or removing existing ones." }, "attribute": "disabled", "reflect": true, "defaultValue": "false" }, "readonly": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Readonly prevents changing the value but allows interaction like focus." }, "attribute": "readonly", "reflect": true, "defaultValue": "false" }, "required": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Marks the control as required." }, "attribute": "required", "reflect": true, "defaultValue": "false" }, "invalid": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Marks the control as invalid." }, "attribute": "invalid", "reflect": true, "defaultValue": "false" }, "loading": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Set to `true` to put the component in the `loading` state,\nand render an indeterminate progress indicator inside.\nThis does _not_ disable the interactivity of the component!" }, "attribute": "loading", "reflect": true, "defaultValue": "false" }, "value": { "type": "string", "mutable": false, "complexType": { "original": "string | FileInfo", "resolved": "FileInfo | string", "references": { "FileInfo": { "location": "import", "path": "../../global/shared-types/file.types" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "Current image to display. Either a URL string or a `FileInfo` with an href." }, "attribute": "value", "reflect": false }, "imageFit": { "type": "string", "mutable": false, "complexType": { "original": "'cover' | 'contain'", "resolved": "\"contain\" | \"cover\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "How the image should fit within the container.\n- `cover` will fill the container and crop excess parts.\n- `contain` will scale the image to fit within the container without cropping." }, "attribute": "image-fit", "reflect": true, "defaultValue": "'cover'" }, "accept": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "A comma-separated list of accepted file types." }, "attribute": "accept", "reflect": true, "defaultValue": "'image/jpeg,image/png,image/heic,.jpg,.jpeg,.png,.heic'" }, "resize": { "type": "unknown", "mutable": false, "complexType": { "original": "ResizeOptions", "resolved": "{ width: number; height: number; fit?: \"cover\" | \"contain\"; type?: \"image/jpeg\" | \"image/png\"; quality?: number; rename?: (originalName: string) => string; }", "references": { "ResizeOptions": { "location": "import", "path": "../../util/image-resize" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "Optional client-side resize before emitting the file.\nIf provided, the selected image will be resized on the client device.\n:::note\nHEIC may not decode in all browsers; when decoding fails, the original\nfile will be emitted. See the examples for more info.\n:::" } } }; } static get states() { return { "objectUrl": {}, "imageError": {}, "isErrorMessagePopoverOpen": {} }; } static get events() { return [{ "method": "change", "name": "change", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the picture changes (first FileInfo only)." }, "complexType": { "original": "FileInfo | undefined", "resolved": "FileInfo", "references": { "FileInfo": { "location": "import", "path": "../../global/shared-types/file.types" } } } }, { "method": "filesRejected", "name": "filesRejected", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when a file is rejected by accept filter." }, "complexType": { "original": "FileInfo[]", "resolved": "FileInfo[]", "references": { "FileInfo": { "location": "import", "path": "../../global/shared-types/file.types" } } } }]; } static get watchers() { return [{ "propName": "value", "methodName": "handleValueChange" }]; } } //# sourceMappingURL=profile-picture.js.map