@limetech/lime-elements
Version:
620 lines (619 loc) • 24.6 kB
JavaScript
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() {
/**
* Defines the language for translations.
* Will translate the translatable strings on the components.
*/
this.language = 'en';
/**
* Placeholder icon of the component, displayed when no image is present.
*/
this.icon = 'user';
/**
* Disables user interaction.
* Prevents uploading new pictures or removing existing ones.
*/
this.disabled = false;
/**
* Readonly prevents changing the value but allows interaction like focus.
*/
this.readonly = false;
/**
* Marks the control as required.
*/
this.required = false;
/**
* Marks the control as invalid.
*/
this.invalid = false;
/**
* Set to `true` to put the component in the `loading` state,
* and render an indeterminate progress indicator inside.
* This does _not_ disable the interactivity of the component!
*/
this.loading = false;
/**
* How the image should fit within the container.
* - `cover` will fill the container and crop excess parts.
* - `contain` will scale the image to fit within the container without cropping.
*/
this.imageFit = 'cover';
/**
* A comma-separated list of accepted file types.
*/
this.accept = 'image/jpeg,image/png,image/heic,.jpg,.jpeg,.png,.heic';
this.imageError = false;
this.isErrorMessagePopoverOpen = false;
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);
};
}
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",
"id": "src/components/date-picker/date.types.ts::Languages",
"referenceLocation": "Languages"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Defines the language for translations.\nWill translate the translatable strings on the components."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "language",
"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."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "label"
},
"icon": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string | Icon",
"resolved": "Icon | string",
"references": {
"Icon": {
"location": "import",
"path": "../../global/shared-types/icon.types",
"id": "src/global/shared-types/icon.types.ts::Icon",
"referenceLocation": "Icon"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Placeholder icon of the component, displayed when no image is present."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "icon",
"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."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "helper-text"
},
"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."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "disabled",
"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."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "readonly",
"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."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "required",
"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."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "invalid",
"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!"
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "loading",
"defaultValue": "false"
},
"value": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string | FileInfo",
"resolved": "FileInfo | string",
"references": {
"FileInfo": {
"location": "import",
"path": "../../global/shared-types/file.types",
"id": "src/global/shared-types/file.types.ts::FileInfo",
"referenceLocation": "FileInfo"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "Current image to display. Either a URL string or a `FileInfo` with an href."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "value"
},
"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."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "image-fit",
"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."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "accept",
"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",
"id": "src/util/image-resize.ts::ResizeOptions",
"referenceLocation": "ResizeOptions"
}
}
},
"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:::"
},
"getter": false,
"setter": false
}
};
}
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",
"id": "src/global/shared-types/file.types.ts::FileInfo",
"referenceLocation": "FileInfo"
}
}
}
}, {
"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",
"id": "src/global/shared-types/file.types.ts::FileInfo",
"referenceLocation": "FileInfo"
}
}
}
}];
}
static get watchers() {
return [{
"propName": "value",
"methodName": "handleValueChange"
}];
}
}