UNPKG

@webqit/oohtml

Version:

A suite of new DOM features that brings language support for modern UI development paradigms: a component-based architecture, data binding, and reactivity.

422 lines (406 loc) 19.7 kB
/** * @imports */ import { isDocument, isShadowRoot } from '@webqit/realdom'; import DOMNamingContext from './DOMNamingContext.js'; import { _wq, _init, _splitOuter, _fromHash, _toHash, getInternalAttrInteraction, internalAttrInteraction } from '../util.js'; /** * @init * * @param Object $config */ export default function init($config = {}) { const { config, window } = _init.call(this, 'namespaced-html', $config, { attr: { namespace: 'namespace', lid: 'id', }, api: { namespace: 'namespace', }, tokens: { lidrefPrefix: '~', lidrefSeparator: ':' }, target: { className: ':target', eventName: ':target', scrolling: true }, }); config.lidSelector = `[${window.CSS.escape(config.attr.lid)}]`; config.namespaceSelector = `[${window.CSS.escape(config.attr.namespace)}]`; window.webqit.DOMNamingContext = DOMNamingContext; exposeAPIs.call(window, config); realtime.call(window, config); } /** * @init * * @param Object config * * @return String */ function lidUtil(config) { const { lidrefPrefix, lidrefSeparator, } = config.tokens; return { escape(str, mode = 1) { return [...str].map(x => !/\w/.test(x) ? (mode === 2 ? `\\\\${x}` : `\\${x}`) : x).join(''); }, lidrefPrefix(escapeMode = 0) { return escapeMode ? this.escape(lidrefPrefix, escapeMode) : lidrefPrefix; }, lidrefSeparator(escapeMode = 0) { return escapeMode ? this.escape(lidrefSeparator, escapeMode) : lidrefSeparator; }, isUuid(str, escapeMode = 0) { return str.startsWith(this.lidrefPrefix(escapeMode)) && str.includes(this.lidrefSeparator(escapeMode)); }, //isLidref( str, escapeMode = 0 ) { return str.startsWith( this.lidrefPrefix( escapeMode ) ) && !str.includes( this.lidrefSeparator( escapeMode ) ); }, toUuid(hash, lid, escapeMode = 0) { return hash.endsWith('-root') ? lid : `${this.lidrefPrefix(escapeMode)}${hash}${this.lidrefSeparator(escapeMode)}${lid}`; }, uuidToId(str, escapeMode = 0) { return this.isUuid(str) ? str.split(this.lidrefSeparator(escapeMode))[1] : str; }, uuidToLidref(str, escapeMode = 0) { return this.isUuid(str) ? `${this.lidrefPrefix(escapeMode)}${str.split(this.lidrefSeparator(escapeMode))[1]}` : str; }, } } /** * @rewriteSelector * * @param String selectorText * @param String namespaceUUID * @param String scopeSelector * @param Bool escapeMode * * @return String */ export function rewriteSelector(selectorText, namespaceUUID, scopeSelector = null, escapeMode = 0) { const window = this, { webqit: { oohtml: { configs: { NAMESPACED_HTML: config } } } } = window; const $lidUtil = lidUtil(config); // Match :scope and relative ID selector const regex = new RegExp(`${scopeSelector ? `:scope|` : ''}#(${$lidUtil.lidrefPrefix(escapeMode + 1)})?([\\w]+${$lidUtil.lidrefSeparator(escapeMode + 1)})?((?:[\\w-]|\\\\.)+)`, 'g'); // Parse potentially combined selectors individually and categorise into categories per whether they have :scope or not const [cat1, cat2] = _splitOuter(selectorText, ',').reduce(([cat1, cat2], selector) => { // The deal: match and replace let quotesMatch, hadScopeSelector; selector = selector.replace(regex, (match, lidrefPrefixMatch, lidrefSeparatorMatch, id, index) => { if (!quotesMatch) { // Lazy: stuff // Match strings between quotes (single or double) and use that qualify matches above // The string: String.raw`She said, "Hello, John. I\"m your friend." or "you're he're" 'f\'"j\'"f'jfjf`; // Should yield: `"Hello, John. I\\"m your friend."`, `"you're he're"`, `'f\\'"j\\'"f'` quotesMatch = [...selector.matchAll(/(["'])(?:(?=(\\?))\2.)*?\1/g)]; } // Qualify match if (quotesMatch.some(q => index > q.index && index + match.length < q.index + q[0].length)) return match; // Replace :scope if (match === ':scope') { hadScopeSelector = true; return scopeSelector; } const isLidref = lidrefPrefixMatch && !lidrefSeparatorMatch; const isUuid = lidrefPrefixMatch && lidrefSeparatorMatch; if (isUuid) { return `#${$lidUtil.escape(match.replace('#', ''), 1)}`; } // Rewrite relative ID selector if (isLidref) { if (config.attr.lid === 'id' && namespaceUUID && !namespaceUUID.endsWith('-root')) { return `#${$lidUtil.toUuid(namespaceUUID, id, 1)}`; } // Fallback to attr-based } // Rewrite absolute ID selector let rewrite; if (config.attr.lid === 'id') { rewrite = `:is(#${id},[id^="${$lidUtil.lidrefPrefix(escapeMode)}"][id$="${$lidUtil.lidrefSeparator(escapeMode)}${id}"])`; } else { rewrite = `:is(#${id},[${window.CSS.escape(config.attr.lid)}="${id}"])`; } return isLidref ? `:is(${rewrite}):not(${scopeSelector ? scopeSelector + ' ' : ''}${config.namespaceSelector} *)` : rewrite; }); // Category 2 has :scope and category 1 does not return hadScopeSelector ? [cat1, cat2.concat(selector)] : [cat1.concat(selector), cat2]; }, [[], []]); // Do the upgrade let newSelectorText; if (scopeSelector && cat1.length) { newSelectorText = [cat1.length > 1 ? `${scopeSelector} :is(${cat1.join(', ')})` : `${scopeSelector} ${cat1[0]}`, cat2.join(', ')].filter(x => x).join(', '); } else { newSelectorText = [...cat1, ...cat2].join(', '); } return newSelectorText; } /** * @param Element node * * @return Object */ export function getOwnNamespaceObject(node) { const window = this; if (!_wq(node).has('namespace')) { const namespaceObj = Object.create(null); _wq(node).set('namespace', namespaceObj); const isDocumentRoot = isDocument(node) || isShadowRoot(node); Object.defineProperty(namespaceObj, Symbol.toStringTag, { get() { return isDocumentRoot ? 'RootNamespaceRegistry' : 'NamespaceRegistry'; } }); } return _wq(node).get('namespace'); } /** * @param Element node * @param Bool forID * * @return Object */ export function getOwnerNamespaceObject(node, forID = false) { const window = this, { webqit: { oohtml: { configs: { NAMESPACED_HTML: config } } } } = window; const isDocumentRoot = isDocument(node) || isShadowRoot(node); return getOwnNamespaceObject.call(window, isDocumentRoot ? node : ((forID ? node.parentNode : node)?.closest/*can be documentFragment when Shadow DOM*/?.(config.namespaceSelector) || node.getRootNode())); } /** * @param Object namespaceObj * * @return String */ export function getNamespaceUUID(namespaceObj) { const isDocumentRoot = Object.prototype.toString.call(namespaceObj) === '[object RootNamespaceRegistry]'; return (_fromHash(namespaceObj) || _toHash(namespaceObj)) + (isDocumentRoot ? '-root' : ''); } /** * Exposes Namespaced HTML with native APIs. * * @param Object config * * @return Void */ function exposeAPIs(config) { const window = this, { webqit: { Observer } } = window; // The Namespace API [window.Document.prototype, window.Element.prototype, window.ShadowRoot.prototype].forEach(prototype => { // No-conflict assertions const type = prototype === window.Document.prototype ? 'Document' : (prototype === window.ShadowRoot.prototype ? 'ShadowRoot' : 'Element'); if (config.api.namespace in prototype) { throw new Error(`The ${type} prototype already has a "${config.api.namespace}" API!`); } // Definitions Object.defineProperty(prototype, config.api.namespace, { get: function () { return Observer.proxy(getOwnNamespaceObject.call(window, this)); } }); }); } /** * Performs realtime capture of elements and builds their relationships. * * @param Object config * * @return Void */ function realtime(config) { const window = this, { webqit: { Observer, realdom, oohtml: { configs }, DOMNamingContext } } = window; // ------------ // APIS // ------------ // See https://wicg.github.io/aom/aria-reflection-explainer.html & https://github.com/whatwg/html/issues/3515 for the ARIA refelction properties idea // See https://www.w3.org/TR/wai-aria-1.1/#attrs_relationships for the relational ARIA attributes const idRefsAttrs = ['aria-owns', 'aria-controls', 'aria-labelledby', 'aria-describedby', 'aria-flowto',]; const idRefAttrs = ['for', 'list', 'form', 'aria-activedescendant', 'aria-details', 'aria-errormessage', 'popovertarget']; const attrList = [config.attr.lid, ...idRefsAttrs, ...idRefAttrs]; const relMap = { id: 'id'/* just in case it's in attrList */, for: 'htmlFor', 'aria-owns': 'ariaOwns', 'aria-controls': 'ariaControls', 'aria-labelledby': 'ariaLabelledBy', 'aria-describedby': 'ariaDescribedBy', 'aria-flowto': 'ariaFlowto', 'aria-activedescendant': 'ariaActiveDescendant', 'aria-details': 'ariaDetails', 'aria-errormessage': 'ariaErrorMessage', 'popovertarget': 'popoverTargetElement' }; const $lidUtil = lidUtil(config); const uuidsToLidrefs = (node, attrName, getter) => { if (!getInternalAttrInteraction(node, attrName) && _wq(node, 'attrOriginals').has(attrName)) { return _wq(node, 'attrOriginals').get(attrName); } const value = getter(); if (getInternalAttrInteraction(node, attrName)) return value; return value && value.split(' ').map(x => (x = x.trim()) && (attrName === config.attr.lid ? $lidUtil.uuidToId : $lidUtil.uuidToLidref).call($lidUtil, x)).join(' '); }; // Intercept getElementById() const getElementByIdDescr = Object.getOwnPropertyDescriptor(window.Document.prototype, 'getElementById'); Object.defineProperty(window.Document.prototype, 'getElementById', { ...getElementByIdDescr, value(id) { return this.querySelector(`#${id}`); // To be rewritten at querySelector() } }); // Intercept querySelector() and querySelectorAll() for (const queryApi of ['querySelector', 'querySelectorAll']) { for (const nodeApi of [window.Document, window.Element]) { const querySelectorDescr = Object.getOwnPropertyDescriptor(nodeApi.prototype, queryApi); Object.defineProperty(nodeApi.prototype, queryApi, { ...querySelectorDescr, value(selector) { return querySelectorDescr.value.call(this, rewriteSelector.call(window, selector, getNamespaceUUID(getOwnNamespaceObject.call(window, this)))); } }); } } // Intercept getAttribute() const getAttributeDescr = Object.getOwnPropertyDescriptor(window.Element.prototype, 'getAttribute'); Object.defineProperty(window.Element.prototype, 'getAttribute', { ...getAttributeDescr, value(attrName) { const getter = () => getAttributeDescr.value.call(this, attrName); return attrList.includes(attrName) && !_wq(this, 'lock').get(attrName) ? uuidsToLidrefs(this, attrName, getter) : getter(); } }); // Hide implementation details on the Attr node too. const propertyDescr = Object.getOwnPropertyDescriptor(window.Attr.prototype, 'value'); Object.defineProperty(window.Attr.prototype, 'value', { ...propertyDescr, get() { const getter = () => propertyDescr.get.call(this); return attrList.includes(this.name) ? uuidsToLidrefs(this.ownerElement, this.name, getter) : getter(); } }); const propertyDescr2 = Object.getOwnPropertyDescriptor(window.Node.prototype, 'nodeValue'); Object.defineProperty(window.Node.prototype, 'nodeValue', { ...propertyDescr2, get() { const getter = () => propertyDescr2.get.call(this); return this instanceof window.Attr && attrList.includes(this.name) ? uuidsToLidrefs(this.ownerElement, this.name, getter) : getter(); } }); // These APIs should return LIDREFS minus the hash part for (const attrName of attrList) { if (!(attrName in relMap)) continue; const domApis = attrName === 'for' ? [window.HTMLLabelElement, window.HTMLOutputElement] : (attrName === 'popovertarget' ? [window.HTMLButtonElement, window.HTMLInputElement] : [window.Element]); for (const domApi of domApis) { const propertyDescr = Object.getOwnPropertyDescriptor(domApi.prototype, relMap[attrName]); if (!propertyDescr) continue; Object.defineProperty(domApi.prototype, relMap[attrName], { ...propertyDescr, get() { const getter = () => propertyDescr.get.call(this, attrName); return uuidsToLidrefs(this, attrName, getter); } }); } } if (config.attr.lid !== 'id') { // Reflect the custom [config.attr.lid] attribute Object.defineProperty(window.Element.prototype, config.attr.lid, { configurable: true, enumerable: true, get() { return this.getAttribute(config.attr.lid); }, set(value) { return this.setAttribute(config.attr.lid, value); } }); } // ------------ // LOCAL IDS & IDREFS // ------------ const attrChange = (entry, attrName, value, callback) => { return internalAttrInteraction(entry, attrName, () => { if (typeof value === 'function') value = value(); return callback(value); }); }; const setupBinding = (entry, attrName, value, newNamespaceObj = null) => { attrChange(entry, attrName, value, value => { const isLidAttr = attrName === config.attr.lid; const namespaceObj = newNamespaceObj || getOwnerNamespaceObject.call(window, entry, isLidAttr); const namespaceUUID = getNamespaceUUID(namespaceObj); if (isLidAttr) { const id = $lidUtil.uuidToId(value); if (Observer.get(namespaceObj, id) !== entry) { const uuid = $lidUtil.toUuid(namespaceUUID, id); if (uuid !== value) { entry.setAttribute('id', uuid); } Observer.set(namespaceObj, id, entry); } } else { _wq(entry, 'attrOriginals').set(attrName, value); // Save original before rewrite const newAttrValue = value.split(' ').map(idref => (idref = idref.trim()) && $lidUtil.isUuid(idref) ? idref : $lidUtil.toUuid(namespaceUUID, idref)).join(' '); entry.setAttribute(attrName, newAttrValue); _wq(namespaceObj).set('idrefs', _wq(namespaceObj).get('idrefs') || new Set); _wq(namespaceObj).get('idrefs').add(entry); } }); }; const cleanupBinding = (entry, attrName, oldValue, prevNamespaceObj = null) => { attrChange(entry, attrName, oldValue, oldValue => { const isLidAttr = attrName === config.attr.lid; const namespaceObj = prevNamespaceObj || getOwnerNamespaceObject.call(window, entry, isLidAttr); if (isLidAttr) { const id = $lidUtil.uuidToId(oldValue); if (Observer.get(namespaceObj, id) === entry) { Observer.deleteProperty(namespaceObj, id); } } else { const newAttrValue = _wq(entry, 'attrOriginals').get(attrName);// oldValue.split( ' ' ).map( lid => ( lid = lid.trim() ) && $lidUtil.uuidToLidref( lid ) ).join( ' ' ); if (entry.hasAttribute(attrName)) entry.setAttribute(attrName, newAttrValue); _wq(namespaceObj).get('idrefs')?.delete(entry); } }); }; // ------------ // NAMESPACE // ------------ realdom.realtime(window.document).query(config.namespaceSelector, record => { const reAssociate = (entry, attrName, oldNamespaceObj, newNamespaceObj) => { if (!entry.hasAttribute(attrName)) return; const attrValue = () => entry.getAttribute(attrName); cleanupBinding(entry, attrName, attrValue/* Current resolved value as-is */, oldNamespaceObj); if (entry.isConnected) { setupBinding(entry, attrName, _wq(entry, 'attrOriginals').get(attrName)/* Saved original value */ || attrValue/* Lest it's ID */, newNamespaceObj); } }; record.exits.forEach(entry => { if (entry.isConnected) { const namespaceObj = getOwnNamespaceObject.call(window, entry); // Detach ID and IDREF associations for (const node of new Set([...Object.values(namespaceObj), ...(_wq(namespaceObj).get('idrefs') || [])])) { for (const attrName of attrList) { reAssociate(node, attrName, namespaceObj); } } } // Detach ID associations const contextsApi = entry[configs.CONTEXT_API.api.contexts]; const ctx = contextsApi.find(DOMNamingContext.kind); // Detach namespace instance if (ctx) { contextsApi.detach(ctx); } }); record.entrants.forEach(entry => { // Claim ID and IDREF associations let newSuperNamespaceObj; const superNamespaceObj = getOwnerNamespaceObject.call(window, entry, true); for (const node of new Set([...Object.values(superNamespaceObj), ...(_wq(superNamespaceObj).get('idrefs') || [])])) { if ((newSuperNamespaceObj = getOwnerNamespaceObject.call(window, node, true)) === superNamespaceObj) continue; for (const attrName of attrList) { reAssociate(node, attrName, superNamespaceObj, newSuperNamespaceObj); } } // Attach namespace instance const contextsApi = entry[configs.CONTEXT_API.api.contexts]; if (!contextsApi.find(DOMNamingContext.kind)) { contextsApi.attach(new DOMNamingContext); } }); }, { id: 'namespace-html:namespace', live: true, subtree: 'cross-roots', timing: 'sync', staticSensitivity: true, eventDetails: true }); // DOM realtime realdom.realtime(window.document).query(`[${attrList.map(attrName => window.CSS.escape(attrName)).join('],[')}]`, record => { // We do some caching to prevent redundanct lookups const namespaceNodesToTest = { forID: new Map, forOther: new Map, }; for (const attrName of attrList) { // Point to the right cache const _namespaceNodesToTest = attrName === config.attr.lid ? namespaceNodesToTest.forID : namespaceNodesToTest.forOther; record.exits.forEach(entry => { if (!entry.hasAttribute(attrName)) return; // Point to the right namespace node let namespaceNodeToTest = _namespaceNodesToTest.get(entry); if (typeof namespaceNodeToTest === 'undefined') { namespaceNodeToTest = (attrName === config.attr.lid ? entry.parentNode : entry)?.closest/*can be documentFragment when Shadow DOM*/?.(config.namespaceSelector) || entry.getRootNode().host; _namespaceNodesToTest.set(entry, namespaceNodeToTest); } if (namespaceNodeToTest && !namespaceNodeToTest.isConnected) return; cleanupBinding(entry, attrName, () => entry.getAttribute(attrName)/* Current resolved value as-is */); }); record.entrants.forEach(entry => { if (!entry.hasAttribute(attrName) || entry.hasAttribute('namespaceesc')) return; setupBinding(entry, attrName, () => entry.getAttribute(attrName)/* Raw value (as-is) that will be saved as original */); }); } namespaceNodesToTest.forID.clear(); namespaceNodesToTest.forOther.clear(); }, { id: 'namespace-html:attrs', live: true, subtree: 'cross-roots', timing: 'sync' }); // Attr realtime realdom.realtime(window.document, 'attr').observe(attrList, records => { for (const record of records) { if (record.oldValue && record.value !== record.oldValue) { cleanupBinding(record.target, record.name, record.oldValue/* Current resolved value as-is */); } if (record.value && record.value !== record.oldValue) { setupBinding(record.target, record.name, record.value/* Raw value (as-is) that will be saved as original */); } } }, { id: 'namespace-html:attr(attrs)', subtree: 'cross-roots', timing: 'sync', newValue: true, oldValue: true }); // ------------ // TARGETS // ------------ let prevTarget; const activateTarget = () => { if (!window.location.hash?.startsWith(`#${$lidUtil.lidrefPrefix()}`)) return; const path = window.location.hash?.substring(`#${$lidUtil.lidrefPrefix()}`.length).split('/').map(s => s.trim()).filter(s => s) || []; const currTarget = path.reduce((prev, segment) => prev && prev[config.api.namespace][segment], window.document); if (prevTarget && config.target.className) { prevTarget.classList.toggle(config.target.className, false); } if (currTarget && currTarget !== window.document) { if (config.target.className) { currTarget.classList.toggle(config.target.className, true); } if (config.target.eventName) { currTarget.dispatchEvent(new window.CustomEvent(config.target.eventName)); } if (config.target.scrolling && path.length > 1) { currTarget.scrollIntoView(); } prevTarget = currTarget; } }; // "hash" realtime window.addEventListener('hashchange', activateTarget); realdom.ready(activateTarget); // ---------------- }