nexwidget
Version:
An ESNext Web Component library.
267 lines (266 loc) • 8.83 kB
JavaScript
import { noChange, render } from 'lit-html/lit-html.js';
import { Debouncer } from 'nexbounce/nexbounce.js';
export * from 'lit-html/lit-html.js';
export * from './lib/add-pending-task.js';
export * from './lib/css-tag.js';
export class Nexwidget extends HTMLElement {
static #reactives = new WeakMap([[Nexwidget, new Set()]]);
static #attributes = new WeakMap([[Nexwidget, new Map()]]);
static get styles() {
return [];
}
static get reactives() {
Nexwidget.#ensureReactives(this);
return [...Nexwidget.#reactives.get(this)];
}
static get attributes() {
Nexwidget.#ensureAttributes(this);
return [...Nexwidget.#attributes.get(this).keys()];
}
static get propertyKeysForObservedAttributes() {
const reactives = new Set(this.reactives);
return this.attributes.filter((key) => reactives.has(key));
}
static get observedAttributes() {
return this.propertyKeysForObservedAttributes.map(Nexwidget.#camelToKebab);
}
static #camelToKebab(name) {
return name
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z])(?=[a-z])/g, '$1-$2')
.toLowerCase();
}
static #ensureReactives(Class) {
const isEnsured = Nexwidget.#reactives.has(Class);
if (!isEnsured) {
const SuperClass = Reflect.getPrototypeOf(Class);
const isSuperClassEnsured = Nexwidget.#reactives.has(SuperClass);
if (!isSuperClassEnsured && SuperClass !== Nexwidget)
Nexwidget.#ensureReactives(SuperClass);
Nexwidget.#reactives.set(Class, Nexwidget.#reactives.get(SuperClass));
}
}
static #ensureAttributes(Class) {
const isEnsured = Nexwidget.#attributes.has(Class);
if (!isEnsured) {
const SuperClass = Reflect.getPrototypeOf(Class);
const isSuperClassEnsured = Nexwidget.#attributes.has(SuperClass);
if (!isSuperClassEnsured && SuperClass !== Nexwidget)
Nexwidget.#ensureAttributes(SuperClass);
Nexwidget.#attributes.set(Class, Nexwidget.#attributes.get(SuperClass));
}
}
static registerAs(tagName) {
customElements.define(tagName, this);
}
static createReactives(reactives) {
Nexwidget.#ensureReactives(this);
const reactivesSet = new Set(reactives);
reactivesSet.forEach((key) => {
const descriptor = Reflect.getOwnPropertyDescriptor(this.prototype, key);
const internalKey = !descriptor?.get ? Symbol(key) : null;
Nexwidget.#reactives.set(
this,
new Set([...Nexwidget.#reactives.get(this), key]),
);
Reflect.defineProperty(this.prototype, key, {
configurable: true,
enumerable: true,
get() {
if (internalKey !== null) return this[internalKey];
else return descriptor.get.call(this);
},
set(value) {
const prevValue = this[key];
descriptor?.set?.call(this, value);
if (internalKey && prevValue !== value) {
this[internalKey] = value;
this.#render();
}
},
});
});
}
static createAttributes(attributes) {
Nexwidget.#ensureAttributes(this);
const attributesMap = new Map(
attributes.map(({ key, type }) => [key, type]),
);
attributesMap.forEach((type, key) => {
const descriptor = Reflect.getOwnPropertyDescriptor(this.prototype, key);
Nexwidget.#attributes.set(
this,
new Map([...Nexwidget.#attributes.get(this), [key, type]]),
);
Reflect.defineProperty(this.prototype, key, {
configurable: true,
enumerable: true,
get() {
descriptor?.get?.call(this);
return this.#getPropertyValueFromAttribute(key);
},
set(value) {
descriptor?.set?.call(this, value);
this.#setAttributeFromProperty(key, value);
},
});
});
}
#renderRoot = this.attachShadow({ mode: 'open' });
#renderDebouncer = new Debouncer();
#isRenderEnabled = false;
#isMounted = false;
#rootPart;
#removedController;
#unmountedController;
#slotObserver = new MutationObserver(this.slotChangedCallback.bind(this));
#animation;
get removedSignal() {
return this.#removedController?.signal;
}
get unmountedSignal() {
return this.#unmountedController?.signal;
}
get template() {
return noChange;
}
get mountAnimation() {
return this.updateOrSlotChangeAnimation;
}
get updateOrSlotChangeAnimation() {
return null;
}
get usesNexwidget() {
return true;
}
#adoptStyles() {
const { styles } = this.constructor;
this.#renderRoot.adoptedStyleSheets = [...styles];
}
//@ts-ignore
#getPropertyValueFromAttribute(key) {
const type = Nexwidget.#attributes.get(this.constructor).get(key);
const attributeKey = Nexwidget.#camelToKebab(key);
switch (type) {
case 'boolean':
return this.hasAttribute(attributeKey);
case 'string':
case 'number':
const typeConstructorName =
type.charAt(0).toUpperCase() + type.slice(1);
const typeConstructor = globalThis[typeConstructorName];
return this.hasAttribute(attributeKey)
? typeConstructor(this.getAttribute(attributeKey))
: null;
default:
throw new RangeError(`Invalid type for attribute.`);
}
}
//@ts-ignore
#setAttributeFromProperty(key, value) {
const type = Nexwidget.#attributes.get(this.constructor).get(key);
const attributeKey = Nexwidget.#camelToKebab(key);
if (value === undefined)
throw new TypeError(`Attribute value cannot be undefined.`);
else if (value !== null && typeof value !== type)
throw new TypeError(`Attribute value doesn't match its type.`);
else
switch (type) {
case 'boolean':
if (value) this.setAttribute(attributeKey, '');
else if (value === null)
throw new TypeError(`Boolean attribute cannot be null.`);
else this.removeAttribute(attributeKey);
break;
case 'string':
case 'number':
if (value === null) this.removeAttribute(attributeKey);
else this.setAttribute(attributeKey, String(value));
break;
default:
throw new RangeError(`Invalid type for attribute.`);
}
}
#render() {
this.#renderDebouncer.enqueue(() => {
if (this.#isRenderEnabled) {
this.#rootPart = render(this.template, this.#renderRoot, {
host: this,
});
requestAnimationFrame(() => {
this.updatedCallback();
if (!this.#isMounted) {
this.slotChangedCallback();
this.#isMounted = true;
this.mountedCallback();
this.#slotObserver.observe(this, {
subtree: true,
characterData: true,
childList: true,
});
}
});
}
});
}
#cleanupRender() {
if (!this.#isRenderEnabled) {
this.#slotObserver.disconnect();
this.#isMounted = false;
this.unmountedCallback();
}
}
getCSSProperty(key) {
return getComputedStyle(this).getPropertyValue(key);
}
attributeChangedCallback(_key, oldValue, newValue) {
if (oldValue !== newValue) this.#render();
}
connectedCallback() {
this.#adoptStyles();
this.addedCallback();
this.#isRenderEnabled = true;
this.#render();
}
disconnectedCallback() {
this.removedCallback();
this.#isRenderEnabled = false;
this.#cleanupRender();
}
addedCallback() {
this.#removedController = new AbortController();
}
updatedCallback() {
this.#animation?.cancel();
if (this.updateOrSlotChangeAnimation !== null)
this.#animation = this.animate(
this.updateOrSlotChangeAnimation.keyframes,
this.updateOrSlotChangeAnimation.options,
);
}
slotChangedCallback() {
this.#animation?.cancel();
if (this.updateOrSlotChangeAnimation !== null)
this.#animation = this.animate(
this.updateOrSlotChangeAnimation.keyframes,
this.updateOrSlotChangeAnimation.options,
);
}
mountedCallback() {
this.#rootPart?.setConnected(true);
this.#animation?.cancel();
if (this.mountAnimation !== null)
this.#animation = this.animate(
this.mountAnimation.keyframes,
this.mountAnimation.options,
);
this.#unmountedController = new AbortController();
}
removedCallback() {
this.#removedController?.abort();
}
unmountedCallback() {
this.#rootPart?.setConnected(false);
this.#unmountedController?.abort();
}
}