@vaadin/component-base
Version:
Vaadin component base mixins
394 lines (327 loc) • 11 kB
JavaScript
/**
* @license
* Copyright (c) 2021 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { dedupeMixin } from '@open-wc/dedupe-mixin';
import { notEqual } from 'lit';
import { get, set } from './path-utils.js';
const caseMap = {};
const CAMEL_TO_DASH = /([A-Z])/gu;
function camelToDash(camel) {
if (!caseMap[camel]) {
caseMap[camel] = camel.replace(CAMEL_TO_DASH, '-$1').toLowerCase();
}
return caseMap[camel];
}
function upper(name) {
return name[0].toUpperCase() + name.substring(1);
}
function parseObserver(observerString) {
const [method, rest] = observerString.split('(');
const observerProps = rest
.replace(')', '')
.split(',')
.map((prop) => prop.trim());
return {
method,
observerProps,
};
}
function getOrCreateMap(obj, name) {
if (!Object.prototype.hasOwnProperty.call(obj, name)) {
// Clone any existing entries (superclasses)
obj[name] = new Map(obj[name]);
}
return obj[name];
}
const PolylitMixinImplementation = (superclass) => {
class PolylitMixinClass extends superclass {
// PolylitMixin, and components using it, force synchronous updates
// in connectedCallback and for properties that are configured to
// be sync. This causes Lit's `change-in-update` warning to be
// logged for almost every component when Lit runs in development
// mode. Since we intentionally force updates, disable the warning.
static enabledWarnings = [];
static createProperty(name, options) {
if ([String, Boolean, Number, Array].includes(options)) {
options = {
type: options,
};
}
if (options && options.reflectToAttribute) {
options.reflect = true;
}
super.createProperty(name, options);
}
static getOrCreateMap(name) {
return getOrCreateMap(this, name);
}
/**
* @protected
* @override
*/
static finalize() {
// Suppress warnings about deprecated overriding ReactiveElement methods
// as the mixin requires those. See https://github.com/lit/lit/pull/4901
if (window.litIssuedWarnings) {
window.litIssuedWarnings.add('no-override-create-property');
window.litIssuedWarnings.add('no-override-get-property-descriptor');
}
super.finalize();
if (Array.isArray(this.observers)) {
const complexObservers = this.getOrCreateMap('__complexObservers');
this.observers.forEach((observer) => {
const { method, observerProps } = parseObserver(observer);
complexObservers.set(method, observerProps);
});
}
}
static addCheckedInitializer(initializer) {
super.addInitializer((instance) => {
// Prevent initializer from affecting superclass
if (instance instanceof this) {
initializer(instance);
}
});
}
static getPropertyDescriptor(name, key, options) {
const defaultDescriptor = super.getPropertyDescriptor(name, key, options);
let result = defaultDescriptor;
// Set the key for this property
this.getOrCreateMap('__propKeys').set(name, key);
if (options.sync) {
result = {
get: defaultDescriptor.get,
set(value) {
const oldValue = this[name];
if (notEqual(value, oldValue)) {
this[key] = value;
this.requestUpdate(name, oldValue, options);
// Enforce synchronous update
if (this.hasUpdated) {
this.performUpdate();
}
}
},
configurable: true,
enumerable: true,
};
}
if (options.readOnly) {
const setter = result.set;
this.addCheckedInitializer((instance) => {
// This is run during construction of the element
instance[`_set${upper(name)}`] = function (value) {
setter.call(instance, value);
};
});
result = {
get: result.get,
set() {
// Do nothing, property is read-only.
},
configurable: true,
enumerable: true,
};
}
if ('value' in options) {
// Set the default value
this.addCheckedInitializer((instance) => {
const value = typeof options.value === 'function' ? options.value.call(instance) : options.value;
if (options.readOnly) {
instance[`_set${upper(name)}`](value);
} else {
instance[name] = value;
}
});
}
if (options.observer) {
const method = options.observer;
// Set this method
this.getOrCreateMap('__observers').set(name, method);
this.addCheckedInitializer((instance) => {
if (!instance[method]) {
console.warn(`observer method ${method} not defined`);
}
});
}
if (options.notify) {
if (!this.__notifyProps) {
this.__notifyProps = new Set();
// eslint-disable-next-line no-prototype-builtins
} else if (!this.hasOwnProperty('__notifyProps')) {
// Clone any existing observers (superclasses)
const notifyProps = this.__notifyProps;
this.__notifyProps = new Set(notifyProps);
}
// Set this method
this.__notifyProps.add(name);
}
if (options.computed) {
const assignComputedMethod = `__assignComputed${name}`;
const observer = parseObserver(options.computed);
this.prototype[assignComputedMethod] = function (...props) {
this[name] = this[observer.method](...props);
};
this.getOrCreateMap('__computedObservers').set(assignComputedMethod, observer.observerProps);
}
if (!options.attribute) {
options.attribute = camelToDash(name);
}
return result;
}
static get polylitConfig() {
return {
asyncFirstRender: false,
};
}
constructor() {
super();
this.__hasPolylitMixin = true;
}
/** @protected */
connectedCallback() {
super.connectedCallback();
// Components like `vaadin-overlay` are teleported to the body element when opened.
// If their opened state is set as an attribute, the teleportation happens immediately
// after they are connected to the DOM. This means they will be outside the scope of
// querySelectorAll in the parent component's `firstUpdated()`. To ensure their reference
// is still registered in the $ map, we propagate the reference here.
const parentHost = this.getRootNode().host;
if (parentHost && parentHost.__hasPolylitMixin && this.id) {
parentHost.$ ||= {};
parentHost.$[this.id] = this;
}
const { polylitConfig } = this.constructor;
if (!this.hasUpdated && !polylitConfig.asyncFirstRender) {
this.performUpdate();
}
}
/** @protected */
firstUpdated() {
super.firstUpdated();
if (!this.$) {
this.$ = {};
}
[...Object.values(this.$), this.renderRoot].forEach((node) => {
node.querySelectorAll('[id]').forEach((node) => {
this.$[node.id] = node;
});
});
}
/** @protected */
ready() {}
/** @protected */
willUpdate(props) {
if (this.constructor.__computedObservers) {
this.__runComplexObservers(props, this.constructor.__computedObservers);
}
}
/** @protected */
updated(props) {
const wasReadyInvoked = this.__isReadyInvoked;
this.__isReadyInvoked = true;
if (this.constructor.__observers) {
this.__runObservers(props, this.constructor.__observers);
}
if (this.constructor.__complexObservers) {
this.__runComplexObservers(props, this.constructor.__complexObservers);
}
if (this.__dynamicPropertyObservers) {
this.__runDynamicObservers(props, this.__dynamicPropertyObservers);
}
if (this.__dynamicMethodObservers) {
this.__runComplexObservers(props, this.__dynamicMethodObservers);
}
if (this.constructor.__notifyProps) {
this.__runNotifyProps(props, this.constructor.__notifyProps);
}
if (!wasReadyInvoked) {
this.ready();
}
}
/**
* Set several properties at once and perform synchronous update.
* @protected
*/
setProperties(props) {
Object.entries(props).forEach(([name, value]) => {
// Use private key and not setter to not trigger
// update for properties marked as `sync: true`.
const key = this.constructor.__propKeys.get(name);
const oldValue = this[key];
this[key] = value;
this.requestUpdate(name, oldValue);
});
// Perform sync update
if (this.hasUpdated) {
this.performUpdate();
}
}
/** @protected */
_createMethodObserver(observer) {
const dynamicObservers = getOrCreateMap(this, '__dynamicMethodObservers');
const { method, observerProps } = parseObserver(observer);
dynamicObservers.set(method, observerProps);
}
/** @protected */
_createPropertyObserver(property, method) {
const dynamicObservers = getOrCreateMap(this, '__dynamicPropertyObservers');
dynamicObservers.set(method, property);
}
/** @private */
__runComplexObservers(props, observers) {
observers.forEach((observerProps, method) => {
if (observerProps.some((prop) => props.has(prop))) {
if (!this[method]) {
console.warn(`observer method ${method} not defined`);
} else {
this[method](...observerProps.map((prop) => this[prop]));
}
}
});
}
/** @private */
__runDynamicObservers(props, observers) {
observers.forEach((prop, method) => {
if (props.has(prop) && this[method]) {
this[method](this[prop], props.get(prop));
}
});
}
/** @private */
__runObservers(props, observers) {
props.forEach((v, k) => {
const observer = observers.get(k);
if (observer !== undefined && this[observer]) {
this[observer](this[k], v);
}
});
}
/** @private */
__runNotifyProps(props, notifyProps) {
props.forEach((_, k) => {
if (notifyProps.has(k)) {
this.dispatchEvent(
new CustomEvent(`${camelToDash(k)}-changed`, {
detail: {
value: this[k],
},
}),
);
}
});
}
/** @protected */
_get(path, object) {
return get(path, object);
}
/** @protected */
_set(path, value, object) {
set(path, value, object);
}
}
return PolylitMixinClass;
};
export const PolylitMixin = dedupeMixin(PolylitMixinImplementation);