@symbiotejs/symbiote
Version:
Symbiote.js - close-to-platform frontend library for building super-powered web components
666 lines (614 loc) • 18.5 kB
JavaScript
import PubSub from './PubSub.js';
import { DICT } from './dictionary.js';
import { UID } from '../utils/UID.js';
import { setNestedProp } from '../utils/setNestedProp.js';
import { prepareStyleSheet } from '../utils/prepareStyleSheet.js';
import PROCESSORS from './tpl-processors.js';
import { parseCssPropertyValue } from '../utils/parseCssPropertyValue.js';
export { html } from './html.js';
export { css } from './css.js';
export { UID, PubSub, DICT }
let autoTagsCount = 0;
/** @template S */
export class Symbiote extends HTMLElement {
/** @type {Boolean} */
#initialized;
/** @type {String} */
#autoCtxName;
/** @type {String} */
#cachedCtxName;
/** @type {PubSub} */
#localCtx;
#stateProxy;
/** @type {Boolean} */
#dataCtxInitialized;
#disconnectTimeout;
#cssDataCache;
#computedStyle;
#boundCssProps;
/** @type {typeof Symbiote} */
// @ts-expect-error
#super = this.constructor;
/** @type {HTMLTemplateElement} */
static __tpl;
get Symbiote() {
return Symbiote;
}
initCallback() {}
renderCallback() {}
#initCallback() {
if (this.#initialized) {
return;
}
this.#initialized = true;
this.initCallback?.();
}
/** @type {String} */
static template;
/**
* @param {String | DocumentFragment} [template]
* @param {Boolean} [shadow]
*/
render(template, shadow = this.renderShadow) {
/** @type {DocumentFragment} */
let fr;
if ((shadow || this.#super.shadowStyleSheets) && !this.shadowRoot) {
this.attachShadow({
mode: 'open',
});
}
if (this.allowCustomTemplate) {
let customTplSelector = this.getAttribute(DICT.USE_TPL_ATTR);
if (customTplSelector) {
let root = this.getRootNode();
/** @type {HTMLTemplateElement} */
// @ts-expect-error
let customTpl = root?.querySelector(customTplSelector) || document.querySelector(customTplSelector);
if (customTpl) {
// @ts-expect-error
template = customTpl.content.cloneNode(true);
} else {
console.warn(`Symbiote template "${customTplSelector}" is not found...`);
}
}
}
if (this.processInnerHtml || this.ssrMode) {
for (let fn of this.tplProcessors) {
fn(this, this);
}
}
if (template || this.#super.template) {
if (this.#super.template && !this.#super.__tpl) {
this.#super.__tpl = document.createElement('template');
this.#super.__tpl.innerHTML = this.#super.template;
}
if (template?.constructor === DocumentFragment) {
fr = template;
} else if (template?.constructor === String) {
let tpl = document.createElement('template');
tpl.innerHTML = template;
// @ts-expect-error
fr = tpl.content.cloneNode(true);
} else if (this.#super.__tpl) {
// @ts-expect-error
fr = this.#super.__tpl.content.cloneNode(true);
}
for (let fn of this.tplProcessors) {
fn(fr, this);
}
}
// for the possible asynchronous call:
let addFr = () => {
if (fr && this.isVirtual) {
this.replaceWith(fr);
} else {
fr && ((shadow && this.shadowRoot.appendChild(fr)) || this.appendChild(fr));
}
this.#initCallback();
this.renderCallback?.();
};
if (this.#super.shadowStyleSheets) {
shadow = true; // is needed for cases when Shadow DOM was created manually for some other purposes
this.shadowRoot.adoptedStyleSheets = [...this.#super.shadowStyleSheets];
}
addFr();
}
/**
* @template {Symbiote} T
* @param {(fr: DocumentFragment | T, fnCtx: T) => void} processorFn
*/
addTemplateProcessor(processorFn) {
this.tplProcessors.add(processorFn);
}
constructor() {
super();
/** @type {S} */
this.init$ = Object.create(null);
/** @type {Object<string, *>} */
this.cssInit$ = Object.create(null);
/** @type {Set<(fr: DocumentFragment | Symbiote, fnCtx: unknown) => void>} */
this.tplProcessors = new Set();
/** @type {Object<string, any>} */
this.ref = Object.create(null);
this.allSubs = new Set();
/** @type {Boolean} */
this.pauseRender = false;
/** @type {Boolean} */
this.renderShadow = false;
/** @type {Boolean} */
this.readyToDestroy = true;
/** @type {Boolean} */
this.processInnerHtml = false;
/** @type {Boolean} */
this.ssrMode = false;
/** @type {Boolean} */
this.allowCustomTemplate = false;
/** @type {Boolean} */
this.ctxOwner = false;
/** @type {Boolean} */
this.isVirtual = false;
/** @type {Boolean} */
this.allowTemplateInits = true;
}
/** @returns {String} */
get autoCtxName() {
if (!this.#autoCtxName) {
this.#autoCtxName = UID.generate();
this.style.setProperty(DICT.CSS_CTX_PROP, `'${this.#autoCtxName}'`);
}
return this.#autoCtxName;
}
/** @returns {String} */
get cssCtxName() {
return this.getCssData(DICT.CSS_CTX_PROP, true);
}
/** @returns {String} */
get ctxName() {
let ctxName = this.getAttribute(DICT.CTX_NAME_ATTR)?.trim() || this.cssCtxName || this.#cachedCtxName || this.autoCtxName;
/**
* Cache last ctx name to be able to access context when element becomes disconnected
*
* @type {String}
*/
this.#cachedCtxName = ctxName;
return ctxName;
}
/** @returns {PubSub} */
get localCtx() {
if (!this.#localCtx) {
this.#localCtx = PubSub.registerCtx({});
}
return this.#localCtx;
}
/** @returns {PubSub} */
get sharedCtx() {
return PubSub.getCtx(this.ctxName, false) || PubSub.registerCtx({}, this.ctxName);
}
/**
* @template {Symbiote} T
* @param {String} prop
* @param {T} fnCtx
*/
static #parseProp(prop, fnCtx) {
/** @type {PubSub} */
let ctx;
/** @type {String} */
let name;
if (prop.startsWith(DICT.SHARED_CTX_PX)) {
ctx = fnCtx.sharedCtx;
name = prop.replace(DICT.SHARED_CTX_PX, '');
} else if (prop.startsWith(DICT.PARENT_CTX_PX)) {
name = prop.replace(DICT.PARENT_CTX_PX, '');
let found = fnCtx;
while (found && !found?.has?.(name)) {
// @ts-expect-error
found = found.parentElement || found.parentNode || found.host;
}
ctx = found?.localCtx || fnCtx.localCtx;
} else if (prop.includes(DICT.NAMED_CTX_SPLTR)) {
let pArr = prop.split(DICT.NAMED_CTX_SPLTR);
ctx = PubSub.getCtx(pArr[0]);
name = pArr[1];
} else if (prop.startsWith(DICT.CSS_DATA_PX)) {
ctx = fnCtx.localCtx;
name = prop;
if (!ctx.has(name)) {
fnCtx.bindCssData(name);
}
} else {
ctx = fnCtx.localCtx;
name = prop;
}
return {
ctx,
name,
};
}
/**
* @template {keyof S} T
* @param {T} prop
* @param {(value: S[T]) => void} handler
* @param {Boolean} [init]
*/
sub(prop, handler, init = true) {
let subCb = (val) => {
if (this.#noInit) {
return;
}
handler(val);
};
let parsed = Symbiote.#parseProp(/** @type {string} */ (prop), this);
if (!parsed.ctx.has(parsed.name)) {
// Avoid *prop binding race:
window.setTimeout(() => {
this.allSubs.add(parsed.ctx.sub(parsed.name, subCb, init));
});
} else {
this.allSubs.add(parsed.ctx.sub(parsed.name, subCb, init));
}
}
/** @param {String} prop */
notify(prop) {
let parsed = Symbiote.#parseProp(prop, this);
parsed.ctx.notify(parsed.name);
}
/** @param {String} prop */
has(prop) {
let parsed = Symbiote.#parseProp(prop, this);
return parsed.ctx.has(parsed.name);
}
/**
* @template {keyof S} T
* @param {String} prop
* @param {S[T]} val
* @param {Boolean} [rewrite]
*/
add(prop, val, rewrite = false) {
let parsed = Symbiote.#parseProp(prop, this);
parsed.ctx.add(parsed.name, val, rewrite);
}
/**
* @param {Partial<S>} obj
* @param {Boolean} [rewrite]
*/
add$(obj, rewrite = false) {
for (let prop in obj) {
this.add(prop, obj[prop], rewrite);
}
}
/** @returns {S} */
get $() {
if (!this.#stateProxy) {
let o = Object.create(null);
this.#stateProxy = new Proxy(o, {
set: (obj, /** @type {String} */ prop, val) => {
let parsed = Symbiote.#parseProp(prop, this);
parsed.ctx.pub(parsed.name, val);
return true;
},
get: (obj, /** @type {String} */ prop) => {
let parsed = Symbiote.#parseProp(prop, this);
return parsed.ctx.read(parsed.name);
},
});
}
return this.#stateProxy;
}
/**
* @param {Partial<S>} kvObj
* @param {Boolean} [forcePrimitives] Force update callbacks for primitive types
*/
set$(kvObj, forcePrimitives = false) {
for (let key in kvObj) {
let val = kvObj[key];
/** @type {unknown[]} */
let primArr = [String, Number, Boolean];
if (forcePrimitives || !primArr.includes(val?.constructor)) {
this.$[key] = val;
} else {
this.$[key] !== val && (this.$[key] = val);
}
}
}
get #ctxOwner() {
return this.ctxOwner || (this.hasAttribute(DICT.CTX_OWNER_ATTR) && this.getAttribute(DICT.CTX_OWNER_ATTR) !== 'false');
}
initAttributeObserver() {
if (!this.attributeMutationObserver) {
this.attributeMutationObserver = new MutationObserver((mutations) => {
for (let mr of mutations) {
if (mr.type === 'attributes') {
let propName = DICT.ATTR_BIND_PX + mr.attributeName;
if (this.has(propName)) {
this.$[propName] = this.getAttribute(mr.attributeName);
}
}
}
});
this.attributeMutationObserver.observe(this, {
attributes: true,
});
}
}
#initDataCtx() {
/** @type {{ [key: string]: string }} */
let attrDesc = this.#super.__attrDesc;
if (attrDesc) {
for (let prop of Object.values(attrDesc)) {
if (!Object.keys(this.init$).includes(prop)) {
this.init$[prop] = '';
}
}
}
for (let prop in this.init$) {
if (prop.startsWith(DICT.SHARED_CTX_PX)) {
this.sharedCtx.add(prop.replace(DICT.SHARED_CTX_PX, ''), this.init$[prop], this.#ctxOwner);
} else if (prop.startsWith(DICT.ATTR_BIND_PX)) {
this.localCtx.add(prop, (this.getAttribute(prop.replace(DICT.ATTR_BIND_PX, '')) || this.init$[prop]));
this.initAttributeObserver();
} else if (prop.includes(DICT.NAMED_CTX_SPLTR)) {
let propArr = prop.split(DICT.NAMED_CTX_SPLTR);
let ctxName = propArr[0].trim();
let propName = propArr[1].trim();
if (ctxName && propName) {
let namedCtx = PubSub.getCtx(ctxName, false);
if (!namedCtx) {
namedCtx = PubSub.registerCtx({}, ctxName);
}
namedCtx.add(propName, this.init$[prop]);
}
} else {
this.localCtx.add(prop, this.init$[prop]);
}
}
for (let cssProp in this.cssInit$) {
this.bindCssData(cssProp, this.cssInit$[cssProp]);
}
this.#dataCtxInitialized = true;
}
get #noInit() {
return !this.isVirtual && !this.isConnected;
}
#initComponent() {
// As `connectedCallback` calls are queued, it could be called after element being detached from DOM
// See example at https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-conformance
if (this.#noInit) {
return;
}
if (this.#disconnectTimeout) {
window.clearTimeout(this.#disconnectTimeout);
}
if (!this.connectedOnce) {
let ctxNameAttrVal = this.getAttribute(DICT.CTX_NAME_ATTR)?.trim();
if (ctxNameAttrVal) {
this.style.setProperty(DICT.CSS_CTX_PROP, `'${ctxNameAttrVal}'`);
}
this.#initDataCtx();
if (this[DICT.SET_LATER_KEY]) {
for (let prop in this[DICT.SET_LATER_KEY]) {
setNestedProp(this, prop, this[DICT.SET_LATER_KEY][prop]);
}
delete this[DICT.SET_LATER_KEY];
}
this.initChildren = [...this.childNodes];
for (let proc of PROCESSORS) {
this.addTemplateProcessor(proc);
}
if (this.pauseRender) {
this.#initCallback();
} else {
if (this.#super.rootStyleSheets) {
/** @type {Document | ShadowRoot} */
// @ts-expect-error
let root = this.getRootNode();
if (!root) {
return;
}
let styleSet = new Set([...root.adoptedStyleSheets, ...this.#super.rootStyleSheets]);
root.adoptedStyleSheets = [...styleSet];
}
this.render();
}
}
this.connectedOnce = true;
}
connectedCallback() {
this.#initComponent();
}
destroyCallback() {}
disconnectedCallback() {
// if element wasn't connected, there is no need to disconnect it
if (!this.connectedOnce) {
return;
}
this.dropCssDataCache();
if (!this.readyToDestroy) {
return;
}
if (this.#disconnectTimeout) {
window.clearTimeout(this.#disconnectTimeout);
}
this.#disconnectTimeout = window.setTimeout(() => {
this.destroyCallback();
if (this.attributeMutationObserver) {
this.attributeMutationObserver.disconnect();
}
for (let sub of this.allSubs) {
sub.remove();
this.allSubs.delete(sub);
}
this.#localCtx && PubSub.deleteCtx(this.#localCtx.uid);
for (let proc of this.tplProcessors) {
this.tplProcessors.delete(proc);
}
}, 100);
}
/**
* @param {String} [tagName]
* @param {Boolean} [isAlias]
*/
static reg(tagName, isAlias = false) {
if (!tagName) {
autoTagsCount++;
tagName = `${DICT.AUTO_TAG_PX}-${autoTagsCount}`;
}
/** @private */
this.__tag = tagName;
let registeredClass = window.customElements.get(tagName);
if (registeredClass) {
if (!isAlias && registeredClass !== this) {
console.warn(
[
`Element with tag name "${tagName}" already registered.`,
`You're trying to override it with another class "${this.name}".`,
`This is most likely a mistake.`,
`New element will not be registered.`,
].join('\n')
);
}
return;
}
window.customElements.define(tagName, isAlias ? class extends this {} : this);
}
static get is() {
if (!this.__tag) {
this.reg();
}
return this.__tag;
}
/** @param {Object<string, string>} desc */
static bindAttributes(desc) {
/** @type {String[]} */
this.observedAttributes = [
...new Set((this.observedAttributes || []).concat(Object.keys(desc)))
];
/** @private */
this.__attrDesc = desc;
}
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal === newVal) {
return;
}
/** @type {String} */
let $prop = this.#super.__attrDesc?.[name];
if ($prop) {
if (this.#dataCtxInitialized) {
this.$[$prop] = newVal;
} else {
this.init$[$prop] = newVal;
}
} else {
this[name] = newVal;
}
}
/**
* @param {String} propName
* @param {Boolean} [silentCheck]
*/
getCssData(propName, silentCheck = false) {
if (!this.#cssDataCache) {
this.#cssDataCache = Object.create(null);
}
if (!Object.keys(this.#cssDataCache).includes(propName)) {
if (!this.#computedStyle) {
this.#computedStyle = window.getComputedStyle(this);
}
let val = this.#computedStyle.getPropertyValue(propName).trim();
try {
this.#cssDataCache[propName] = parseCssPropertyValue(val);
} catch (e) {
!silentCheck && console.warn(`CSS Data error: ${propName}`);
this.#cssDataCache[propName] = null;
}
}
return this.#cssDataCache[propName];
}
/** @param {String} ctxPropName */
#extractCssName(ctxPropName) {
return ctxPropName
.split('--')
.map((part, idx) => {
return idx === 0 ? '' : part;
})
.join('--');
}
updateCssData = () => {
this.dropCssDataCache();
this.#boundCssProps?.forEach((ctxProp) => {
let val = this.getCssData(this.#extractCssName(ctxProp), true);
val !== null && this.$[ctxProp] !== val && (this.$[ctxProp] = val);
});
};
/**
* @param {String} propName
* @param {any} [initValue] Uses empty string by default to make value useful in template
*/
bindCssData(propName, initValue = '') {
if (!this.#boundCssProps) {
this.#boundCssProps = new Set();
}
this.#boundCssProps.add(propName);
let val = this.getCssData(this.#extractCssName(propName), true);
val === null && (val = initValue);
propName.startsWith(DICT.CSS_DATA_PX)
// To prevent prop name parsing in cycle:
? this.localCtx.add(propName, val)
: this.add(propName, val);
}
dropCssDataCache() {
this.#cssDataCache = null;
this.#computedStyle = null;
}
/**
* @param {String} propName
* @param {Function} [handler]
* @param {Boolean} [isAsync]
*/
defineAccessor(propName, handler, isAsync) {
let localPropName = '#' + propName;
this[localPropName] = this[propName];
Object.defineProperty(this, propName, {
set: (val) => {
this[localPropName] = val;
if (isAsync) {
window.setTimeout(() => {
handler?.(val);
});
} else {
handler?.(val);
}
},
get: () => {
return this[localPropName];
},
});
this[propName] = this[localPropName];
}
/** @param {String | CSSStyleSheet} styles */
static addRootStyles(styles) {
if (!this.rootStyleSheets) {
/** @type {CSSStyleSheet[]} */
this.rootStyleSheets = [];
}
this.rootStyleSheets.push(prepareStyleSheet(styles));
}
/** @param {String | CSSStyleSheet} styles */
static addShadowStyles(styles) {
if (!this.shadowStyleSheets) {
/** @type {CSSStyleSheet[]} */
this.shadowStyleSheets = [];
}
this.shadowStyleSheets.push(prepareStyleSheet(styles));
}
/** @param {String | CSSStyleSheet} styles */
static set rootStyles(styles) {
this.rootStyleSheets = [];
this.addRootStyles(styles);
}
/** @param {String | CSSStyleSheet} styles */
static set shadowStyles(styles) {
this.shadowStyleSheets = [];
this.addShadowStyles(styles);
}
}
export default Symbiote;