@exadel/esl
Version:
Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components
171 lines (170 loc) • 6.45 kB
JavaScript
// Private key to store mixin instances
const STORE = (window.Symbol || String)('__esl_mixins');
// Singleton for registry
let global;
/** Registry to store and initialize {@link ESLMixinElement} instances */
export class ESLMixinRegistry {
constructor() {
/** Map that stores available mixins under their identifier (attribute) */
this.store = new Map();
/** MutationObserver instance to track DOM changes and init mixins on-fly */
this.mutation$$ = new MutationObserver(this._onMutation.bind(this));
if (global)
return global;
// eslint-disable-next-line @typescript-eslint/no-this-alias
global = this;
}
/** Array of registered mixin tags */
get observedAttributes() {
const attrs = [];
this.store.forEach((mixin, name) => attrs.push(name));
return attrs;
}
/** Registers mixin definition using {@link ESLMixinElement} constructor */
register(mixin) {
if (!mixin.is || mixin.is.indexOf('-') === -1) {
throw new DOMException(`[ESL]: Illegal mixin attribute name "${mixin.is}"`, 'NotSupportedError');
}
const registered = this.store.get(mixin.is);
if (registered && registered !== mixin) {
throw new DOMException(`[ESL]: Attribute ${mixin.is} is already occupied by another mixin`, 'InUseAttributeError');
}
if (!registered) {
this.store.set(mixin.is, mixin);
this.invalidateRecursive(document.documentElement, mixin.is);
this.resubscribe();
}
}
/** Resubscribes DOM observer */
resubscribe(root = document.documentElement) {
// Don't let flushed changes from being unhandled
this._onMutation(this.mutation$$.takeRecords());
// Resubscribe for all observed attributes
this.mutation$$.disconnect();
this.mutation$$.observe(root, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: this.observedAttributes,
attributeOldValue: true
});
}
/**
* Invalidates all mixins on the element and subtree
* @param root - root HTMLElement to start traversing
* @param name - optional filter for mixin name
*/
invalidateRecursive(root = document.body, name) {
var _a;
if (!root)
return;
name ? this.invalidate(root, name) : this.invalidateAll(root);
if (!((_a = root.children) === null || _a === void 0 ? void 0 : _a.length))
return;
for (const child of root.children) {
this.invalidateRecursive(child, name);
}
}
/**
* Invalidates all mixins on the element
* @param el - host element to invalidate mixins
*/
invalidateAll(el) {
const hasStore = Object.hasOwnProperty.call(el, STORE);
this.store.forEach((mixin, name) => {
if (el.hasAttribute(name)) {
ESLMixinRegistry.init(el, mixin);
}
else if (hasStore) {
ESLMixinRegistry.destroy(el, name);
}
});
}
/**
* Invalidates passed mixin name on the element
* @param el - host element to invalidate mixin
* @param name - mixin name to invalidate
* @param oldValue - optional previous value of mixins attribute
*/
invalidate(el, name, oldValue = null) {
const newValue = el.getAttribute(name);
if (newValue === null)
return ESLMixinRegistry.destroy(el, name);
const instance = ESLMixinRegistry.get(el, name);
if (instance) {
instance.attributeChangedCallback(name, oldValue, newValue);
}
else {
const type = this.store.get(name);
type && ESLMixinRegistry.init(el, type);
}
}
/** Handles DOM {@link MutationRecord} list */
_onMutation(mutations) {
mutations.forEach((record) => {
if (record.type === 'attributes' && record.attributeName) {
this.invalidate(record.target, record.attributeName, record.oldValue);
}
if (record.type === 'childList') {
record.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE)
this.invalidateRecursive(node);
});
record.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE)
ESLMixinRegistry.destroyAll(node);
});
}
});
}
/** @returns mixin instance by name */
static get(el, mixin = this.is) {
const store = el[STORE];
return (store && store[mixin]) || null;
}
/** @returns all mixins initialized on passed host element */
static getAll(el) {
const store = el[STORE];
return store ? Object.values(store) : [];
}
/** @returns if the passed mixin exists on the element */
static has(el, mixin) {
return !!this.get(el, mixin);
}
/** Sets mixin instance to the element store */
static set(el, mixin) {
if (!Object.hasOwnProperty.call(el, STORE))
Object.defineProperty(el, STORE, { value: {}, configurable: true });
const store = el[STORE];
store[mixin.constructor.is] = mixin;
}
/** Inits mixin instance on the element */
static init(el, Mixin) {
if (this.has(el, Mixin.is))
return null;
const instance = new Mixin(el);
ESLMixinRegistry.set(el, instance);
instance.connectedCallback();
return instance;
}
/** Destroys passed mixin on the element */
static destroy(el, mixin) {
const store = el[STORE];
if (!store)
return;
const instance = store[mixin];
if (!instance)
return;
instance.disconnectedCallback();
delete store[mixin];
}
/** Destroys all mixins on the element and its subtree */
static destroyAll(el) {
var _a;
const store = el[STORE];
store && Object.keys(store).forEach((name) => ESLMixinRegistry.destroy(el, name));
if (!((_a = el.children) === null || _a === void 0 ? void 0 : _a.length))
return;
Array.prototype.forEach.call(el.children, (child) => ESLMixinRegistry.destroyAll(child));
}
}