UNPKG

@symbiotejs/symbiote

Version:

Symbiote.js - zero-dependency close-to-platform frontend library to build super-powered web components

804 lines (748 loc) 23.6 kB
import PubSub from './PubSub.js'; import { warnMsg, devState } from './warn.js'; import { DICT } from './dictionary.js'; import { animateOut } from './animateOut.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 { PubSub, DICT } let autoTagsCount = 0; // @ts-ignore - Trusted Types is a browser API, not in standard TS defs const trustedHTML = globalThis.trustedTypes ? trustedTypes.createPolicy('symbiote', { createHTML: (s) => s }) : { createHTML: (s) => s }; /** @template S */ export class Symbiote extends HTMLElement { /** @type {IntersectionObserver} */ static lazyObserver; /** @type {Boolean} */ #initialized; /** @type {String} */ #cachedCtxName; /** @type {PubSub} */ #localCtx; #stateProxy; /** @type {Boolean} */ #dataCtxInitialized; #destroyTimeout; #cssDataCache; #computedStyle; #boundCssProps; /** @type {Map<string, {ctx: PubSub, name: string}>} */ #parsedPropCache; /** @type {typeof Symbiote} */ // @ts-expect-error #super = this.constructor; /** @type {HTMLTemplateElement} */ static __tpl; static set devMode(val) { devState.devMode = val; } static get devMode() { return devState.devMode; } 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 { warnMsg(5, this.localName, customTplSelector); } } } // Resolve isoMode: hydrate if children exist, render template otherwise // For Shadow DOM components, SSR content is in shadowRoot (declarative shadow DOM), // not in light DOM childNodes (those are slot content) if (this.isoMode && !globalThis.__SYMBIOTE_SSR) { let ssrContainer = this.shadowRoot || this; this.ssrMode = ssrContainer.childNodes.length > 0; } let clientSSR = this.ssrMode && !globalThis.__SYMBIOTE_SSR; if (this.processInnerHtml || clientSSR) { for (let fn of this.templateProcessors) { fn(this, this); // Declarative Shadow DOM: also hydrate existing shadowRoot if (clientSSR && this.shadowRoot) { fn(this.shadowRoot, this); } } } if (!clientSSR && (template || this.#super.template)) { if (this.#super.template && !this.#super.__tpl) { this.#super.__tpl = document.createElement('template'); this.#super.__tpl.innerHTML = trustedHTML.createHTML(this.#super.template); } // @ts-expect-error - nodeType works for both DOM nodes and linkedom fragments if (template?.nodeType === 11) { fr = /** @type {DocumentFragment} */ (template); } else if (template?.constructor === String) { let tpl = document.createElement('template'); tpl.innerHTML = trustedHTML.createHTML(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.templateProcessors) { 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(); try { this.renderCallback?.(); } catch (e) { if (!globalThis.__SYMBIOTE_SSR) throw e; } }; if (this.#super.shadowStyleSheets) { shadow = true; // is needed for cases when Shadow DOM was created manually for some other purposes if (!clientSSR) { this.shadowRoot.adoptedStyleSheets = [...this.#super.shadowStyleSheets]; } } addFr(); } constructor() { super(); /** @type {S} */ this.init$ = Object.create(null); /** @type {Object<string, *>} */ this.cssInit$ = Object.create(null); /** @type {Set<(fr: DocumentFragment | Symbiote, fnCtx: Symbiote) => void>} */ this.templateProcessors = 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.isoMode = false; /** @type {Boolean} */ this.allowCustomTemplate = false; /** @type {Boolean} */ this.isVirtual = false; /** @type {Boolean} */ this.allowTemplateInits = true; /** @type {Boolean} */ this.lazyMode = false; } /** @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; /** * 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 = new PubSub({}); } 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; let first = prop.charCodeAt(0); // Fast path for common local props (no prefix, no /) // Char codes: * = 42, ^ = 94, @ = 64, + = 43, - = 45 if (first !== 42 && first !== 94 && first !== 64 && first !== 43 && first !== 45 && !prop.includes('/')) { return { ctx: fnCtx.localCtx, name: prop }; } if (first === 42) { ctx = fnCtx.sharedCtx; name = prop.slice(1); } else if (first === 94) { name = prop.slice(1); 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('/')) { let slashIdx = prop.indexOf('/'); ctx = PubSub.getCtx(prop.slice(0, slashIdx), false); if (!ctx) { return null; } name = prop.slice(slashIdx + 1); } else if (first === 45 && prop.charCodeAt(1) === 45) { 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) { // Named context not found — defer subscription let slashIdx = /** @type {string} */ (prop).indexOf('/'); if (slashIdx !== -1) { let ctxName = /** @type {string} */ (prop).slice(0, slashIdx); let propName = /** @type {string} */ (prop).slice(slashIdx + 1); if (!PubSub.pendingDeps.has(ctxName)) { PubSub.pendingDeps.set(ctxName, []); } PubSub.pendingDeps.get(ctxName).push(() => { let ctx = PubSub.getCtx(ctxName, false); if (!ctx) return; let sub = ctx.sub(propName, subCb, init); if (sub) { this.allSubs.add(sub); } }); } return; } if (!parsed.ctx.has(parsed.name)) { // Avoid *prop binding race: window.queueMicrotask(() => { 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); if (!parsed) return; parsed.ctx.notify(parsed.name); } /** @param {String} prop */ has(prop) { let parsed = Symbiote.#parseProp(prop, this); if (!parsed) return false; 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); if (!parsed) return; 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) => { // Fast path: local prop (no prefix, no /) let first = prop.charCodeAt(0); if (first !== 42 && first !== 94 && first !== 64 && first !== 43 && first !== 45 && !prop.includes('/')) { this.localCtx.pub(prop, val); } else { let parsed = Symbiote.#parseProp(prop, this); if (!parsed) return true; parsed.ctx.pub(parsed.name, val); } return true; }, get: (obj, /** @type {String} */ prop) => { let first = prop.charCodeAt(0); if (first !== 42 && first !== 94 && first !== 64 && first !== 43 && first !== 45 && !prop.includes('/')) { return this.localCtx.read(prop); } let parsed = Symbiote.#parseProp(prop, this); if (!parsed) return undefined; 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.localCtx.pub(key, val); } else { this.localCtx.read(key) !== val && this.localCtx.pub(key, val); } } } 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.localCtx.pub(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)) { let sharedName = prop.replace(DICT.SHARED_CTX_PX, ''); let sharedVal = this.init$[prop]; if (!this.ctxName) { if (Symbiote.devMode) { warnMsg(6, this.localName, sharedName); } } else { if (Symbiote.devMode && this.sharedCtx.has(sharedName)) { let existing = this.sharedCtx.read(sharedName); if (existing !== sharedVal && typeof sharedVal !== 'function') { warnMsg(7, this.localName, sharedName); } } this.sharedCtx.add(sharedName, sharedVal); } } 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.#destroyTimeout) { window.clearTimeout(this.#destroyTimeout); } 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.templateProcessors.add(proc); } if (this.pauseRender) { this.#initCallback(); } else { if (this.#super.rootStyleSheets) { // Skip adopted sheets when hydrating SSR (inline <style> tags already present): let hydrating = this.ssrMode || (this.isoMode && (this.shadowRoot || this).childNodes.length > 0); if (!hydrating) { /** @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() { if (this.lazyMode) { if (!Symbiote.lazyObserver) { Symbiote.lazyObserver = new IntersectionObserver((entries) => { entries.forEach((ent) => { /** @type {any} */ let tgt = ent.target; if (ent.isIntersecting) { tgt.style.minHeight = ''; tgt.style.minWidth = ''; tgt.#initComponent(); } else { tgt.#lazyDestroy(); } }); }); } Symbiote.lazyObserver.observe(this); } else { this.#initComponent(); } } #lazyDestroy() { if (!this.connectedOnce) { return; } let rect = this.getBoundingClientRect(); if (rect.height) { this.style.minHeight = rect.height + 'px'; this.style.minWidth = rect.width + 'px'; } if (this.shadowRoot) { this.shadowRoot.innerHTML = ''; } else { this.innerHTML = ''; } for (let sub of this.allSubs) { sub.remove(); this.allSubs.delete(sub); } this.#localCtx = null; this.templateProcessors.clear(); this.connectedOnce = false; this.#dataCtxInitialized = false; this.#initialized = false; } destroyCallback() {} /** * Animate an element out, then remove it. * Sets `[leaving]` attribute, waits for CSS `transitionend`, then calls `.remove()`. * @param {HTMLElement} el * @returns {Promise<void>} */ static animateOut = animateOut; destructionDelay = 100; disconnectedCallback() { if (globalThis.__SYMBIOTE_SSR) return; if (this.lazyMode && Symbiote.lazyObserver) { Symbiote.lazyObserver.unobserve(this); } // if element wasn't connected, there is no need to disconnect it if (!this.connectedOnce) { return; } this.dropCssDataCache(); if (!this.readyToDestroy) { return; } if (this.#destroyTimeout) { window.clearTimeout(this.#destroyTimeout); } this.#destroyTimeout = window.setTimeout(() => { this.destroyCallback(); if (this.attributeMutationObserver) { this.attributeMutationObserver.disconnect(); } for (let sub of this.allSubs) { sub.remove(); this.allSubs.delete(sub); } this.#localCtx = null; for (let proc of this.templateProcessors) { this.templateProcessors.delete(proc); } }, this.destructionDelay); } /** * @param {String} [tagName] * @param {Boolean} [isAlias] * @returns {typeof Symbiote} */ 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) { warnMsg(8, tagName, registeredClass.name, this.name); } return this; } window.customElements.define(tagName, isAlias ? class extends this {} : this); return this; } static get is() { if (!this.__tag) { this.reg(); } return this.__tag; } /** @param {Object<string, string>} desc */ static bindAttributes(desc) { /** @type {String[]} */ let attrs = [ // @ts-ignore - observedAttributes is a native HTMLElement static getter ...new Set((this.observedAttributes || []).concat(Object.keys(desc))) ]; Object.defineProperty(this, 'observedAttributes', { configurable: true, get() { return attrs; }, }); /** @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.localCtx.pub($prop, newVal); } else { this.init$[$prop] = newVal; } } else { this[name] = newVal; } } /** * @param {String} propName * @param {Boolean} [silentCheck] */ getCssData(propName, silentCheck = false) { if (globalThis.__SYMBIOTE_SSR) { return null; } 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 && warnMsg(9, this.localName, 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.localCtx.read(ctxProp) !== val && (this.localCtx.pub(ctxProp, val)); }); }; /** * @param {String} propName * @param {any} [initValue] Uses empty string by default to make value useful in template */ bindCssData(propName, initValue = '') { if (devState.devMode && (this.ssrMode || this.isoMode)) { warnMsg(10, this.localName, propName); } 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.queueMicrotask(() => { 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;