web-component-base-class
Version:
Base class to simplify the creation of web components
335 lines (310 loc) • 13.5 kB
JavaScript
import { createQuickAccess, dashesToCamelCase, camelCaseToDashes } from './tools.js';
const base = 'web-component-base-element';
export class WebComponentBaseClass extends HTMLElement {
#eventHandlers;
#props;
/**
* @property {() => void} [attached] - implement this inside your derived class to handle additional initialization after your class has been attached to the DOM
* @property {(component: WebComponentBaseClass) => void} [onAttached] - assign a function to this outside your derived class to handle additional initialization after your class has been attached to the DOM
* @property {() => void} [detached] - implement this inside your derived class to handle additional destruction after your class has been detached from the DOM
* @property {(component: WebComponentBaseClass) => void} [onDetached] - assign a function to this outside your derived class to handle additional destruction after your class has been detached from the DOM
* @property {undefined | HTMLElement} $ - directly access any elements with an id from your shadow dom, the id names will be in camelCase
* @property {(selector?: string) => HTMLElement | undefined} $$ - runs querySelector on your shadow DOM
* @property {(selector?: string) => HTMLElement[]} $$$ - runs querySelectorAll on your shadow DOM
*/
/**
* Get the name for the web component. Must be implemented by the class that extends from WebComponentBaseClass
* @returns {string} The name of the web component
*/
get is() { return this.tagName.toLowerCase() ?? base; }
/**
* Get the template. This will return empty string by default so the class that extends from this must provide its own template
* This should be HTML code as a string (e.g. <div>My template content...</div>
* @returns {string} The template to be used with the web component as a string
*/
static get template() { return ''; }
/**
* Get the properties for this web component. Derived classes can override this if they want to provide properties. By default, there are no properties
* @returns {object} The properties object
*/
static get properties() { return {}; }
/**
* Get an array containing all attributes that have an observer attached
* @returns {array} Array containing all attributes that have an observer
*/
static get observedAttributes() { return (this.properties) ? Object.keys(this.properties).map((name) => camelCaseToDashes(name)) : []; }
/**
* Constructor for this base class
*/
constructor() {
super();
this.#props = {};
this.$ = undefined;
this.$$ = () => undefined;
this.$$$ = () => [];
console.assert(this.is !== base, 'Error, don\'t forget to register your custom element using window.customElements.define(\'name-of-element\', MyElement)');
this.#createShadowDOM(this.constructor.template);
this.#eventHandlers = [];
}
/**
* Attach an event handler to the given element. This function will automatically clean all event handlers when the web component gets removed from the DOM
* @param {HTMLElement} element The element to which we are attaching the event handler
* @param {string} eventName The name of the event (e.g. click, mouse down etc)
* @param {function} callback The handler function to be called for the event
* @returns {function | undefined} A function that can be used to remove this event handler or undefined if this event handler already existed
*/
addAutoEventListener(element, eventName, callback) {
let cleanupFunction;
if (!this.#eventHandlers.find((handler) => handler.element === element && handler.event === eventName && handler.handler === callback)) {
this.#eventHandlers.push({ element, event: eventName, handler: callback });
element.addEventListener(eventName, callback);
cleanupFunction = () => this.removeAutoEventListener(element, eventName, callback);
}
return cleanupFunction;
}
/**
* Remove an event handler that was previously attached by a call to addAutoEventListener
* @param {HTMLElement} element The element from which the event will be removed
* @param {string} eventName The name of the event to remove
* @param {function} callback The callback that was previously added for the event
*/
removeAutoEventListener(element, eventName, callback) {
const eventIndex = this.#eventHandlers.findIndex((handler) => handler.element === element && handler.event === eventName && handler.handler === callback);
if (eventIndex !== -1) {
this.#eventHandlers.splice(eventIndex, 1);
element.removeEventListener(eventName, callback);
}
}
/**
* recreate the quick access object using the current content of the shadow dom
* use this when you manually add items to or remove items from the DOM
*/
refreshQuickAccess() {
this.$ = createQuickAccess(this.shadowRoot, 'id');
}
/**
* @private
* Called by the system when the web component has been added to the DOM
*/
connectedCallback() {
this.#handleConnected(this.constructor.properties);
// this function should be implemented INSIDE the derived cass, if you want to do additional initialization after the component gets attached to the DOM
if (this.attached) {
this.attached();
}
// this function should be implemented OUTSIDE the derived cass, if you want to do additional initialization after the component gets attached to the DOM
if (this.onAttached) {
this.onAttached(this);
}
// setter to set the onAttached function, this setter only exist after the component has been attached and replaces the onAttached member
// this will make sure that onAttached gets called immediately if the component was already attached
Object.defineProperty(this, 'onAttached', {
get() { return undefined; },
set(callback) { callback(this); },
});
}
/**
* @private
* Called by the system when the web component has been removed from the DOM
*/
disconnectedCallback() {
// this function should be implemented INSIDE the derived class when needed to handle the component being removed from the DOM
if (this.detached) {
this.detached();
}
// this function can be implemented OUTSIDE of the derived class, if you want to be notified if the component has been removed from the DOM
if (this.onDetached) {
this.onDetached(this);
}
// remove any auto event handler that was added
this.#eventHandlers.forEach((handler) => handler.element.removeEventListener(handler.event, handler.handler));
}
/**
* @private
* Called by the system if an attribute value is changed
* @param {string} attribute The name of the attribute that is changed
* @param {*} oldValue The old value for the attribute
* @param {*} newValue The new value for the attribute
*/
attributeChangedCallback(attribute, oldValue, newValue) {
const propertyName = dashesToCamelCase(attribute);
this.#ensureQuickAccess(this);
// boolean are handled differently because the absence of the value also means false and the presence of the value also means true
if (this.constructor.properties[propertyName] && this.constructor.properties[propertyName].type === Boolean) {
oldValue = !!(oldValue === '' || (oldValue && oldValue !== 'false'));
newValue = !!(newValue === '' || (newValue && newValue !== 'false'));
}
// we set our variable and the setter will handle the rest
if (oldValue !== newValue) {
this[propertyName] = newValue;
}
}
/**
* Create quick access members for this component. This function will add the possibility to access child elements that have an id, through
* the $ member, it also allows to do a querySelector on the shadow dom through the $$ function and it will add querySelectorAll through the $$$ function
* The $$$ function will return the child members as an array and not as an node list
*/
#ensureQuickAccess() {
if (!this.$) { // make sure we didn't do this already
this.$ = createQuickAccess(this.shadowRoot, 'id');
this.$$ = (selector) => this.shadowRoot.querySelector(selector);
this.$$$ = (selector) => Array.from(this.shadowRoot.querySelectorAll(selector));
}
}
/**
* Initialize the web component after it has been added to the DOM
* @param {object} properties The object that contains any web component specific properties
*/
#handleConnected(properties) {
this.#ensureQuickAccess();
if (properties) {
const originalValues = {};
Object.keys(properties).forEach((propertyKey) => {
const property = properties[propertyKey];
const attributeName = camelCaseToDashes(propertyKey);
originalValues[propertyKey] = this[propertyKey];
Object.defineProperty(this, propertyKey, {
get() { return this.#props[propertyKey]; },
set(value) {
const oldValue = this.#props[propertyKey];
let toAttribute = (convertValue) => convertValue.toString();
switch (property.type) {
case Array:
this.#props[propertyKey] = (typeof value === 'string') ? JSON.parse(value) : Array.isArray(value) ? value : [];
toAttribute = (convertValue) => JSON.stringify(convertValue);
break;
case Boolean:
this.#props[propertyKey] = value && value !== 'false';
toAttribute = () => '';
break;
case Number:
this.#props[propertyKey] = ((value === undefined) ? 0 : Number(value)) || 0;
break;
case Object:
this.#props[propertyKey] = (typeof value === 'string') ? JSON.parse(value) : (typeof value === 'object') ? value : {};
toAttribute = (convertValue) => JSON.stringify(convertValue);
break;
case String:
this.#props[propertyKey] = ((value === undefined || value === null) ? '' : String(value)) || '';
break;
}
if (property.observer) {
if (typeof property.observer === 'function') {
if (oldValue !== this.#props[propertyKey]) {
property.observer(this, this.#props[propertyKey], oldValue);
}
} else if (this[property.observer]) {
if (oldValue !== this.#props[propertyKey]) {
this[property.observer](this.#props[propertyKey], oldValue);
}
} else {
console.warn(`The observer with the name: '${property.observer}' was not found inside the class for web component ${this.is}. Make sure that you added a public function with the name '${property.observer}' to the class.`);
}
}
if (property.reflectToAttribute) {
if (this.#props[propertyKey]) {
this.setAttribute(attributeName, toAttribute(this.#props[propertyKey]));
} else {
this.removeAttribute(attributeName);
}
}
},
});
});
Object.keys(properties).forEach((propertyKey) => {
const property = properties[propertyKey];
const attributeName = camelCaseToDashes(propertyKey);
let userInitialized;
const originalValue = originalValues[propertyKey];
if (originalValue) {
switch (property.type) {
case Array:
if (Array.isArray(originalValue)) {
this[propertyKey] = originalValue;
}
break;
case Boolean:
if (typeof originalValue === 'boolean') {
this[propertyKey] = originalValue;
}
break;
case Number:
if (typeof originalValue === 'number') {
this[propertyKey] = originalValue;
}
break;
case Object:
if (typeof originalValue === 'object') {
this[propertyKey] = originalValue;
}
break;
case String:
if (typeof originalValue === 'string') {
this[propertyKey] = originalValue;
}
break;
}
}
if (this.hasAttribute(attributeName)) {
userInitialized = this.getAttribute(attributeName);
}
if (property.reflectToAttribute || this[propertyKey] === undefined) {
// use the user specified value if it was specified
if (userInitialized !== undefined) {
switch (property.type) {
case Array:
this[propertyKey] = JSON.parse(userInitialized);
break;
case Boolean:
this[propertyKey] = userInitialized !== 'false';
break;
case Number:
this[propertyKey] = Number(userInitialized);
break;
case Object:
this[propertyKey] = JSON.parse(userInitialized);
break;
case String:
this[propertyKey] = String(userInitialized);
break;
}
} else { // use the default value
switch (property.type) {
case Array:
this[propertyKey] = property.value || [];
break;
case Boolean:
this[propertyKey] = property.value || false;
break;
case Number:
this[propertyKey] = property.value || 0;
break;
case Object:
this[propertyKey] = property.value || {};
break;
case String:
this[propertyKey] = property.value || '';
break;
}
}
}
});
}
}
/**
* Create the shadow DOM and attach it to the given web component instance
* @param {string} componentTemplate The id of the web component template
*/
#createShadowDOM(componentTemplate) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = componentTemplate.trim();
let templateInstance = tempDiv.firstChild;
if (!(templateInstance instanceof HTMLTemplateElement)) {
templateInstance = document.createElement('template');
templateInstance.innerHTML = tempDiv.innerHTML;
}
// create the shadow DOM root here
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(templateInstance.content.cloneNode(true));
}
}