UNPKG

@jinntec/fore

Version:

Fore - declarative user interfaces in plain HTML

1,497 lines (1,325 loc) β€’ 61.5 kB
import { Fore } from './fore.js'; import './fx-instance.js'; import { FxModel } from './fx-model.js'; import '@jinntec/jinn-toast'; import { evaluateXPathToNodes, evaluateXPathToString, createNamespaceResolver } from './xpath-evaluation.js'; import getInScopeContext from './getInScopeContext.js'; import { XPathUtil } from './xpath-util.js'; import { FxRepeatAttributes } from './ui/fx-repeat-attributes.js'; import { FxBind } from './fx-bind.js'; /** * Makes the dirty state of the form. * * Clean when there are no changes yet or all is submitted, * Dirty when there are unsaved changes. * * We might need a 'saving' state later */ const dirtyStates = { CLEAN: 'clean', DIRTY: 'dirty', }; async function waitForFunctionLibs(rootEl) { const libs = Array.from(rootEl.querySelectorAll('fx-functionlib')); await Promise.all( libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve())) ); } /** * Main class for Fore.Outermost container element for each Fore application. * * Root element for Fore. Kicks off initialization and displays messages. * * fx-fore is the outermost container for each form. A form can have exactly one model * with arbitrary number of instances. * * Main responsibilities are initialization and updating of model and instances, update of UI (refresh) and global messaging. * * @event compute-exception - dispatched in case the dependency graph is circular * @event refresh-done - dispatched after a refresh() run * @event ready - dispatched after Fore has fully been initialized * @event error - dispatches error when template expression fails to evaluate * * @ts-check */ export class FxFore extends HTMLElement { static outermostHandler = null; static draggedItem = null; // Records init gate events that have already happened for a given target (document/window/element). // This prevents β€œmissed gate” situations when an fx-fore is replaced (e.g. via src loading) // after the init event already fired. static _initEventState = new WeakMap(); static _hasSeenInitEvent(target, eventName) { const set = FxFore._initEventState.get(target); return !!(set && set.has(eventName)); } static _markInitEventSeen(target, eventName) { let set = FxFore._initEventState.get(target); if (!set) { set = new Set(); FxFore._initEventState.set(target, set); } set.add(eventName); } static get observedAttributes() { return ['src', 'selector']; } static get properties() { return { /** * wether to create nodes that are missing in the loaded data and * auto-create nodes when there's a binding found in the UI. */ createNodes: { type: Boolean, }, /** * ignore certain nodes for template expression search */ ignoreExpressions: { type: String, }, /** * merge-partial */ mergePartial: { type: Boolean, }, /** * Setting this marker attribute will refresh the UI in a lazy fashion just updating elements being * in viewport. * * this feature is still experimental and should be used with caution and extra testing */ lazyRefresh: { type: Boolean, }, model: { type: Object, }, ready: { type: Boolean, }, strict: { type: Boolean, }, /** * */ validateOn: { type: String, }, version: { type: String, }, }; } /** * attaches handlers for * * - `model-construct-done` to trigger the processing of the UI * - `message` - to display a message triggered by an fx-message action * - `error` - to display an error message * - 'compute-exception` - warn about circular dependencies in graph */ constructor() { super(); this.version = '[VI]Version: {version} - built on {date}[/VI]'; /** * @type {import('./fx-model.js').FxModel} */ this.model = null; this.inited = false; this._initGatesPromise = null; this._warnedWaitForDeprecation = false; this._srcLoadPromise = null; // this.addEventListener('model-construct-done', this._handleModelConstructDone); // todo: refactoring - these should rather go into connectedcallback this.addEventListener('message', this._displayMessage); // this.addEventListener('error', this._displayError); this.addEventListener('error', this._logError); this.addEventListener('warn', this._displayWarning); // this.addEventListener('log', this._logError); window.addEventListener('compute-exception', e => { console.error('circular dependency: ', e); }); this.ready = false; this.storedTemplateExpressionByNode = new Map(); // Stores the outer most action handler. If an action handler is already running, all // updates are included in that one this.outermostHandler = null; this.copiedElements = new WeakSet(); this.dirtyState = dirtyStates.CLEAN; this.showConfirmation = false; // Batching for observer notifications during refresh this.isRefreshPhase = false; /** * The model items that will be updated next refresh * * @type {Set<ModelItem|import('./ui/UIElement.js').UIElement} */ this.batchedNotifications = new Set(); const style = ` :host { display: block; } :host ::slotted(fx-model){ display:none; } #modalMessage .dialogActions{ text-align:center; } .overlay { position: fixed; top: 0; bottom: 0; left: 0; right: 0; background: rgba(0, 0, 0, 0.7); transition: all 500ms; visibility: hidden; opacity: 0; z-index:10; } .overlay.show { visibility: visible; opacity: 1; } .popup { margin: 70px auto; background: #fff; border-radius: 5px; width: 30%; position: relative; transition: all 5s ease-in-out; padding: 20px; } .popup h2 { margin-top: 0; width:100%; background:#eee; position:absolute; top:0; right:0; left:0; height:40px; border-radius: 5px; } .popup .close { position: absolute; top: 3px; right: 10px; transition: all 200ms; font-size: 30px; font-weight: bold; text-decoration: none; color: #333; } .popup .close:focus{ outline:none; } .popup .close:hover { color: #06D85F; } #messageContent{ margin-top:40px; } .warning{ background:orange; } `; const html = ` <!-- <slot name="errors"></slot> --> <jinn-toast id="message" gravity="bottom" position="left"></jinn-toast> <jinn-toast id="sticky" gravity="bottom" position="left" duration="-1" close="true" data-class="sticky-message"></jinn-toast> <jinn-toast id="error" text="error" duration="-1" data-class="error" close="true" position="right" gravity="top" escape-markup="false"></jinn-toast> <jinn-toast id="warn" text="warning" duration="5000" data-class="warning" position="left" gravity="top"></jinn-toast> <slot id="default"></slot> <slot name="messages"></slot> <div id="modalMessage" class="overlay"> <div class="popup"> <h2></h2> <a class="close" href="#" onclick="event.target.parentNode.parentNode.classList.remove('show')" autofocus>&times;</a> <div id="messageContent"></div> </div> </div> <slot name="event"></slot> `; this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style> ${style} </style> ${html} `; this.toRefresh = []; this.initialRun = true; this._scanForNewTemplateExpressionsNextRefresh = false; this.repeatsFromAttributesCreated = false; this.validateOn = this.hasAttribute('validate-on') ? this.getAttribute('validate-on') : 'update'; // this.mergePartial = this.hasAttribute('merge-partial')? true:false; this.mergePartial = false; this.createNodes = this.hasAttribute('create-nodes') ? true : false; this._localNamesWithChanges = new Set(); this.setAttribute('role', 'form'); // set aria role this._pendingRefresh = false; } /** * Parse a list of target specs. * * We accept both comma- and whitespace-separated lists (for backward compatibility with `wait-for`). * Each token can be: * - "self" (default) * - "closest" (closest fx-fore) * - "document" * - "window" * - a CSS selector (no whitespace) */ _parseTargetList(raw) { if (!raw) return []; return raw .split(/[\s,]+/) .map(s => s.trim()) .filter(Boolean); } _findBySelector(sel) { const roots = [this.getRootNode?.() ?? document, document]; for (const r of roots) { if (r && 'querySelector' in r) { const el = r.querySelector(sel); if (el) return el; } } return null; } _isReadyTarget(el) { return !!( el && (el.ready === true || (el.classList && el.classList.contains('fx-ready')) || (typeof el.hasAttribute === 'function' && el.hasAttribute('ready'))) ); } /** * Collect all init gates derived from attributes. * * - `wait-for` (DEPRECATED) becomes: init-on="ready" + init-on-target=<list> * - `init-on` / `init-on-target` define a generic event gate */ _collectInitGates() { const gates = []; const waitForRaw = this.getAttribute('wait-for'); if (waitForRaw) { if (!this._warnedWaitForDeprecation) { console.warn( '[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.', ); this._warnedWaitForDeprecation = true; } const deps = this._parseTargetList(waitForRaw); for (const dep of deps) { gates.push({ event: 'ready', targetSpec: dep }); } } const initOn = this.getAttribute('init-on'); const initOnTargetRaw = this.getAttribute('init-on-target'); if (initOn || initOnTargetRaw) { const eventName = initOn || 'ready'; const targets = initOnTargetRaw ? this._parseTargetList(initOnTargetRaw) : ['self']; for (const t of targets) { gates.push({ event: eventName, targetSpec: t }); } } return gates; } _waitForEvent(target, eventName, isSatisfiedFn = null) { // If a caller provides an explicit satisfaction check, honor it first. if (typeof isSatisfiedFn === 'function' && isSatisfiedFn(target)) { FxFore._markInitEventSeen(target, eventName); return Promise.resolve(); } // Sticky gate: if this event already happened on this target, don't wait again. if (FxFore._hasSeenInitEvent(target, eventName)) { return Promise.resolve(); } return new Promise(resolve => { const ac = new AbortController(); const on = () => { FxFore._markInitEventSeen(target, eventName); ac.abort(); resolve(); }; target.addEventListener(eventName, on, { once: true, signal: ac.signal }); }); } _waitForMatchingEvent(eventName, matchesEventFn, recheckFn = null) { if (typeof recheckFn === 'function' && recheckFn()) { return Promise.resolve(); } return new Promise(resolve => { const root = document; const cleanupAll = () => { root.removeEventListener(eventName, onEvent, true); if (mo) mo.disconnect(); }; const onEvent = ev => { if (matchesEventFn(ev)) { cleanupAll(); resolve(); } }; root.addEventListener(eventName, onEvent, true); // Only used for `ready` (or any other gate that provides a recheck function) let mo = null; if (typeof recheckFn === 'function') { mo = new MutationObserver(() => { if (recheckFn()) { cleanupAll(); resolve(); } }); mo.observe(document.documentElement, { childList: true, subtree: true }); } }); } _waitForInitGate({ event, targetSpec }) { // Direct targets if (targetSpec === 'self') { const satisfied = event === 'ready' ? t => this._isReadyTarget(t) : null; return this._waitForEvent(this, event, satisfied); } if (targetSpec === 'document') { return this._waitForEvent(document, event); } if (targetSpec === 'window') { return this._waitForEvent(window, event); } // Special: closest fx-fore if (targetSpec === 'closest') { const recheckFn = event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null; const matchesFn = ev => { const t = ev.target; return t?.tagName === 'FX-FORE' && t.contains(this); }; return this._waitForMatchingEvent(event, matchesFn, recheckFn); } // Selector targets const selector = targetSpec; const recheckFn = event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null; if (typeof recheckFn === 'function' && recheckFn()) { return Promise.resolve(); } const matchesFn = ev => { // Prefer composedPath() so events coming from inside shadow DOM still match const path = typeof ev.composedPath === 'function' ? ev.composedPath() : []; for (const n of path) { if (n && n.matches && n.matches(selector)) return true; } const t = ev.target; return !!(t && t.closest && t.closest(selector)); }; return this._waitForMatchingEvent(event, matchesFn, recheckFn); } /** * Wait until all configured init gates are satisfied. * This is the single consolidation point for init gating. */ _waitForInitGates() { if (this._initGatesPromise) return this._initGatesPromise; const gates = this._collectInitGates(); if (!gates.length) { this._initGatesPromise = Promise.resolve(); return this._initGatesPromise; } this._initGatesPromise = Promise.all(gates.map(g => this._waitForInitGate(g))).then( () => undefined, ); return this._initGatesPromise; } _onSlotChange = async ev => { // 1) Capture the slot element BEFORE any await const slotEl = ev.currentTarget; if (!(slotEl instanceof HTMLSlotElement)) return; // avoid double init if (this.inited) return; // 2) Wait for init gates (init-on / init-on-target / wait-for) try { await this._waitForInitGates(); } catch (e) { console.warn('init gating failed', e); return; } // 3) Bail if we got disconnected/replaced while waiting if (!this.isConnected) return; if (this.ignoreExpressions) { this.ignoredNodes = Array.from(this.querySelectorAll(this.ignoreExpressions)); } // 4) Safely read assigned content const getAssignedElements = () => { if (typeof slotEl.assignedElements === 'function') { return slotEl.assignedElements({ flatten: true }); } // Fallback for odd engines/polyfills return (slotEl.assignedNodes({ flatten: true }) || []).filter( n => n.nodeType === Node.ELEMENT_NODE, ); }; // SAFE: slotEl is the actual event source, not a fresh query const children = getAssignedElements(); let modelElement = children.find(modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL'); if (!modelElement) { const generatedModel = document.createElement('fx-model'); this.appendChild(generatedModel); modelElement = generatedModel; // We are going to get a new slotchange event immediately, because we changed a slot. // so cancel this one. return; } if (!modelElement.inited) { console.info( `%cFore running ... ${this.id ? '#' + this.id : ''}`, 'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); const variables = new Map(); (function registerVariables(node) { for (const child of node.children) { if ('setInScopeVariables' in child) { child.setInScopeVariables(variables); } registerVariables(child); } })(this); // Ensure all function libraries are loaded/registered before model construction, // so binds/calculate/XPath evaluations can safely call them. const libs = Array.from(this.querySelectorAll('fx-functionlib')); await Promise.all(libs.map(l => l.readyPromise || Promise.resolve())); await modelElement.modelConstruct(); console.log("varbindings ",this._instanceVarBindings); this._handleModelConstructDone(); } this._createRepeatsFromAttributes(); this.inited = true; }; attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'src') { this.src = newValue; if (!newValue) { // Reset so a later src assignment can load again this._srcLoadPromise = null; return; } if (this.isConnected) { this._maybeLoadFromSrc(); } return; } if (name === 'selector') { // Selector changes should affect a pending src-load if (this.isConnected && this.src && !this._srcLoadPromise) { this._maybeLoadFromSrc(); } } } _maybeLoadFromSrc() { if (!this.src) return null; if (this._srcLoadPromise) return this._srcLoadPromise; this._srcLoadPromise = (async () => { await this._waitForInitGates(); if (!this.isConnected) return; const selector = this.getAttribute('selector') || 'fx-fore'; await Fore.loadForeFromSrc(this, this.src, selector); })(); return this._srcLoadPromise; } connectedCallback() { const modelElement = Array.from(this.children).find( modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL', ); this.model = modelElement; this.style.visibility = 'hidden'; // console.time('init'); this.strict = !!this.hasAttribute('strict'); /* document.re('ready', (e) =>{ if(e.target !== this){ // e.preventDefault(); console.log('>>> e', e); console.log('event this', this); // console.log('event eventPhase', e.eventPhase); // console.log('event cancelable', e.cancelable); console.log('event target', e.target); console.log('event composed', e.composedPath()); console.log('<<< event stopping'); e.stopPropagation(); }else{ console.log('event proceed', this); } // e.stopImmediatePropagation(); },true); */ this.ignoreExpressions = this.hasAttribute('ignore-expressions') ? this.getAttribute('ignore-expressions') : null; this.lazyRefresh = this.hasAttribute('refresh-on-view'); if (this.lazyRefresh) { const options = { root: null, rootMargin: '0px', threshold: 0.3, }; this.intersectionObserver = new IntersectionObserver(this.handleIntersect, options); } this.src = this.hasAttribute('src') ? this.getAttribute('src') : null; if (this.src) { this._maybeLoadFromSrc(); return; } this._injectDevtools(); // const slot = this.shadowRoot.querySelector('slot#default'); const slot = this.shadowRoot?.querySelector('slot') || this.querySelector('slot'); if (slot) slot.addEventListener('slotchange', this._onSlotChange); this.addEventListener('path-mutated', () => { this.someInstanceDataStructureChanged = true; }); this.addEventListener('refresh', () => { this.refresh(true); }); if (this.hasAttribute('show-confirmation')) { this.showConfirmation = true; } } /** * Ensure there is an fx-var for each fx-instance in this fx-fore's fx-model scope. * * - For instances with an @id, create `$id` with value `instance('id')`. * - For the first instance WITHOUT an @id, create `$default` with value `instance()`. * - IMPORTANT: if an instance has id="default", we STILL bind `$default` to `instance()` * (avoids recursion / stack overflow during fx-var refresh in some cycles). * * Vars are inserted as direct children of `<fx-fore>` immediately before `<fx-model>`. * The method is idempotent. */ _ensureInstanceVars() { if (this.__instanceVarsEnsured) return; this.__instanceVarsEnsured = true; // Resolve this fx-fore's own fx-model (not nested ones) const model = this.querySelector(':scope > fx-model'); if (!model) return; // Collect instances that are direct children of this model (doc order) const instances = Array.from(model.querySelectorAll(':scope > fx-instance')); // Collect existing fx-var names at fx-fore scope (author-defined and previously generated) const existingVars = new Set( Array.from(this.querySelectorAll(':scope > fx-var')) .map(v => (v.getAttribute('name') || '').trim()) .filter(Boolean), ); let defaultAssigned = false; for (const inst of instances) { const rawId = (inst.getAttribute('id') || '').trim(); // First id-less instance => $default = instance() if (!rawId) { if (defaultAssigned) continue; defaultAssigned = true; const name = 'default'; if (existingVars.has(name)) continue; const fxVar = document.createElement('fx-var'); fxVar.setAttribute('name', name); fxVar.setAttribute('value', 'instance()'); fxVar.setAttribute('data-generated', 'instance-var'); this.insertBefore(fxVar, model); existingVars.add(name); continue; } // Normal id-based instance var const name = rawId; if (existingVars.has(name)) continue; const fxVar = document.createElement('fx-var'); fxVar.setAttribute('name', name); // IMPORTANT: avoid `instance('default')` recursion in fx-var refresh if (name === 'default') { fxVar.setAttribute('value', 'instance()'); } else { fxVar.setAttribute('value', `instance('${name}')`); } fxVar.setAttribute('data-generated', 'instance-var'); this.insertBefore(fxVar, model); existingVars.add(name); } } _injectDevtools() { if (this.ownerDocument.querySelector('fx-devtools')) { // There's already a devtools, so we can ignore this one. // One devtools can focus multiple fore elements return; } const { search } = window.location; const urlParams = new URLSearchParams(search); if (urlParams.has('inspect')) { const devtools = document.createElement('fx-devtools'); document.body.appendChild(devtools); } if (urlParams.has('lens')) { const lens = document.createElement('fx-lens'); document.body.appendChild(lens); lens.setAttribute('open', 'open'); } } /** * Signal something happened with an element with the given local name. This will be used in the * next (non-forceful) refresh to detect whether a component (usually a repeat) should update * * @param {string} localNameOfElement */ signalChangeToElement(localNameOfElement) { this._localNamesWithChanges.add(localNameOfElement); } /** * Raise a flag that there might be new template expressions under some node. This happens with * repeats updating (new repeat items can have new template expressions) or switches changing their case (new case = new raw HTML) */ scanForNewTemplateExpressionsNextRefresh() { // TODO: also ask for the root of any new HTML: this can prevent some very deep queries. this._scanForNewTemplateExpressionsNextRefresh = true; } markAsClean() { console.log('marking as clean', this); this.addEventListener( 'value-changed', () => { console.log('MARK as modified', this) this.dirtyState = dirtyStates.DIRTY; this.classList.toggle('fx-modified') }, { once: true }, ); this.dirtyState = dirtyStates.CLEAN; this.classList.remove('fx-modified'); this.querySelectorAll('.visited').forEach(el => el.classList.remove('visited')); } /** * loads a Fore from an URL given by `src`. * * Will extract the `fx-fore` element from that target file and use and replace current `fx-fore` element with the loaded one. * @private */ async _loadFromSrc() { return this._maybeLoadFromSrc(); } /** * refreshes the UI by using IntersectionObserver API. This is the handler being called * by the observer whenever elements come into / move out of viewport. * @param entries * @param observer */ handleIntersect(entries, observer) { // console.time('refreshLazy'); entries.forEach(entry => { const { target } = entry; const fore = Fore.getFore(target); // Skip if this is the initial run of the fore element // This check prevents issues with nested fx-fore elements loaded via fx-control if (fore.initialRun) return; if (entry.isIntersecting) { // console.log('in view', entry); // console.log('repeat in view entry', entry.target); // const target = entry.target; // if(target.hasAttribute('refresh-on-view')){ target.classList.add('loaded'); // } // todo: too restrictive here? what if target is a usual html element? shouldn't it refresh downwards? if (typeof target.refresh === 'function') { // console.log('refreshing target', target); target.refresh(target, true); } else { // console.log('refreshing children', target); Fore.refreshChildren(target, true); } } }); entries[0].target.getOwnerForm().dispatchEvent(new CustomEvent('refresh-done')); // console.timeEnd('refreshLazy'); } evaluateToNodes(xpath, context) { return evaluateXPathToNodes(xpath, context, this); } disconnectedCallback() { this.removeEventListener('dragstart', this.dragstart); /* this.removeEventListener('model-construct-done', this._handleModelConstructDone); this.removeEventListener('message', this._displayMessage); this.removeEventListener('error', this._displayError); this.storedTemplateExpressionByNode=null; this.shadowRoot = undefined; */ } /** * @param {(boolean|{reason:'index-function'})} [force]fx-fore */ /** * @param {(boolean|{reason:'index-function'})} [force] */ /** * @param {(boolean|{reason:'index-function'})} [force] */ async refresh(force) { // If we're already refreshing, do NOT drop the request. // Queue a hard refresh and return a promise that resolves when the next refresh finishes. if (this.isRefreshing) { // keep "strongest" request: any true means hard refresh this._pendingRefresh = this._pendingRefresh || force === true; return new Promise(resolve => { this.addEventListener('refresh-done', () => resolve(), { once: true }); }); } this.isRefreshing = true; this.isRefreshPhase = true; try { if (force === true || this.initialRun) { console.log('πŸ”„ πŸ”΄πŸ”΄πŸ”΄ ### full refresh() on ', this); await Fore.refreshChildren(this, force); } else { await this._processBatchedNotifications(); } if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) { this._updateTemplateExpressions(); this._scanForNewTemplateExpressionsNextRefresh = false; } this._processTemplateExpressions(); this.isRefreshPhase = false; this.initialRun = false; this.style.visibility = 'visible'; console.info( `%c βœ… refresh-done on #${this.id}`, 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', this.getModel().modelItems, ); Fore.dispatch(this, 'refresh-done', {}); const subFores = Array.from(this.querySelectorAll('fx-fore')); for (const subFore of subFores) { if (subFore.ready) { await subFore.refresh(true); } } } finally { this.isRefreshing = false; // If anything requested a refresh while we were refreshing, run exactly one more. // This prevents "dropped" refresh requests (your timeout). if (this._pendingRefresh) { const pendingHard = this._pendingRefresh === true; this._pendingRefresh = false; // Important: do NOT await in finally without clearing flags first. await this.refresh(pendingHard); } } } /** * Add a ModelItem to the batch of notifications to be processed at the end of the refresh phase * @param {ModelItem | import('./ui/UIElement.js').UIElement} item - The ModelItem or UI Element to add to the batch */ addToBatchedNotifications(item) { if (!this.batchedNotifications.has(item)) { // console.log('adding to batched notifications', item); this.batchedNotifications.add(item); } } /** * Process all batched notifications at the end of the refresh phase */ _processBatchedNotifications() { if (this.batchedNotifications.size > 0) { console.log(`πŸ”„ 🎯 ### processing ${ this.batchedNotifications.size} batched notifications`); console.log('πŸ”„ 🎯 ### processing ', Array.from(this.batchedNotifications)); // console.log(`πŸ” Processing ${this.batchedNotifications.size} batched notifications`); // Process all batched notifications this.batchedNotifications.forEach(entry => { // console.log('batched update', entry); // handle repeatitems created via data-ref if (entry.classList && entry.classList.contains('fx-repeatitem')) { Fore.refreshChildren(entry, true); } if (entry && typeof entry.refresh === 'function') { // Entry is a Ui Element // Force refresh for this whole subtree const uiElement = /** @type {import('./ui/UIElement.js').UIElement} */ (entry); if (!uiElement.ownerDocument.contains(uiElement)) { // Something already removed this ui element. Skip. return; } uiElement.refresh(true); } const nonrelevant = Array.from(this.querySelectorAll('[nonrelevant]')); // loop nonrelevant elements if (nonrelevant) { nonrelevant.forEach(entry => { if (entry.refresh) { entry.refresh(); } }); } if (entry.observers) { // Item is a model item entry.observers.forEach(observer => { // console.log('πŸ” processing observer', observer); if (typeof observer.update === 'function') { // console.log('updating observer', observer); observer.update(entry); } }); } }); // Update template expressions after processing batched notifications // This ensures template expressions are re-evaluated when data changes this._processTemplateExpressions(); // Clear the batch this.batchedNotifications.clear(); } } /** * entry point for processing of template expression enclosed in '{}' brackets. * * Expressions are found with an XPath search. For each node an entry is added to the storedTemplateExpressionByNode map. * * * @private */ _updateTemplateExpressions() { const search = "(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]"; const tmplExpressions = evaluateXPathToNodes(search, this, this); // console.log('template expressions found ', tmplExpressions); if (!this.storedTemplateExpressions) { this.storedTemplateExpressions = []; } // console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length); if(!tmplExpressions) return; /* storing expressions and their nodes for re-evaluation */ Array.from(tmplExpressions).forEach(node => { const ele = node.nodeType === Node.ATTRIBUTE_NODE ? node.ownerElement : node.parentNode; if (ele.closest('fx-fore') !== this) { // We found something in a sub-fore. Act like it's not there return; } if (this.storedTemplateExpressionByNode.has(node)) { // If the node is already known, do not process it twice return; } const expr = this._getTemplateExpression(node); // console.log('storedTemplateExpressionByNode', this.storedTemplateExpressionByNode); if (expr) { this.storedTemplateExpressionByNode.set(node, expr); } }); // console.log('stored template expressions ', this.storedTemplateExpressionByNode); // TODO: Should we clean up nodes that existed but are now gone? this._processTemplateExpressions(); } _processTemplateExpressions() { // console.log('processing template expressions ', this.storedTemplateExpressionByNode); for (const node of Array.from(this.storedTemplateExpressionByNode.keys())) { if (node.nodeType === Node.ATTRIBUTE_NODE) { // Attribute nodes are not contained by the document, but their owner elements are! if (!XPathUtil.contains(this, node.ownerElement)) { this.storedTemplateExpressionByNode.delete(node); continue; } } else if (!XPathUtil.contains(this, node)) { // For all other nodes, if this `fore` element does not contain them, they are dead this.storedTemplateExpressionByNode.delete(node); continue; } this._processTemplateExpression({ node, expr: this.storedTemplateExpressionByNode.get(node), }); } } // eslint-disable-next-line class-methods-use-this _processTemplateExpression(exprObj) { // console.log('processing template expression ', exprObj); const { expr } = exprObj; const { node } = exprObj; // console.log('expr ', expr); this.evaluateTemplateExpression(expr, node); } /** * evaluate a template expression on a node either text- or attribute node. * @param {string} expr The string to parse for expressions * @param {Node} node the node which will get updated with evaluation result */ evaluateTemplateExpression(expr, node) { // ### do not evaluate template expressions within nonrelevant sections if (node.nodeType === Node.ATTRIBUTE_NODE && node.ownerElement.closest('[nonrelevant]')) return; if (node.nodeType === Node.TEXT_NODE && node.parentNode.closest('[nonrelevant]')) return; if (node.nodeType === Node.ELEMENT_NODE && node.closest('[nonrelevant]')) return; // ---- IMPORTANT GUARD ---- // Prevent JSON object/array literals in fx-insert@origin from being treated as // template expressions (they contain {...} but are not XPath templates). if (node.nodeType === Node.ATTRIBUTE_NODE) { const el = node.ownerElement; if (el && el.localName === 'fx-insert' && node.name === 'origin') { const v = String(node.value ?? '').trim(); const isJsonLiteral = (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']')); if (isJsonLiteral) return; } } // ------------------------- // The element that "defines" the template expression is the correct basis for: // - namespace resolution (xmlns lookup) // - fx-var scoping (in-scope variables) // - context() in repeats (repeat item detection) const definitionElement = node.nodeType === Node.ATTRIBUTE_NODE ? node.ownerElement : node.nodeType === Node.TEXT_NODE ? (node.parentElement || node.parentNode) : node; const formElement = definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE ? definitionElement : this; const replaced = String(expr ?? '').replace(/{[^}]*}/g, match => { if (match === '{}') return match; const naked = match.substring(1, match.length - 1); const inscope = getInScopeContext(node, naked); if (!inscope) { return match; } try { // IMPORTANT: // Do NOT pass `null` as the 4th argument here. // Passing `null` suppresses variable collection, which hides implicit vars // like `$default`. return evaluateXPathToString(naked, inscope, formElement); } catch (error) { console.warn('ignoring unparseable expr', error); return match; } }); // Update to the new value only if it changed (avoid iframe/image reload etc.) if (node.nodeType === Node.ATTRIBUTE_NODE) { const parent = node.ownerElement; if (parent.getAttribute(node.nodeName) !== replaced) { parent.setAttribute(node.nodeName, replaced); } } else if (node.nodeType === Node.TEXT_NODE) { if (node.textContent !== replaced) { node.textContent = replaced; } } } // eslint-disable-next-line class-methods-use-this _getTemplateExpression(node) { if (this.ignoredNodes) { if (node.nodeType === Node.ATTRIBUTE_NODE) { node = node.ownerElement; } const found = this.ignoredNodes.find(n => n.contains(node)); if (found) return null; } if (node.nodeType === Node.ATTRIBUTE_NODE) { return node.value; } if (node.nodeType === Node.TEXT_NODE) { return node.textContent.trim(); } return null; } /** * called when `model-construct-done` event is received to * start initing the UI. * * @private */ _handleModelConstructDone() { if (this.showConfirmation) { window.addEventListener('beforeunload', event => { if (this.dirtyState === dirtyStates.DIRTY) { event.preventDefault(); return true; } return false; }); } this._initUI(); } /** * If there's no instance element found in a fx-model during init it will construct * an instance from UI bindings. * * @returns {Promise<void>} * @private */ async _lazyCreateInstance() { const model = this.querySelector('fx-model'); // ##### lazy creation should NOT take place if there's a parent Fore using shared instances const parentFore = this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE ? this.parentNode.closest('fx-fore') : null; if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { console.log('fragment', this.parentNode); } if (parentFore) { const shared = parentFore .getModel() .instances.filter(shared => shared.hasAttribute('shared')); if (shared.length !== 0) return; } // still need to catch just in case... try { if (model.instances.length === 0) { // console.log('### lazy creation of instance'); const generatedInstance = document.createElement('fx-instance'); model.appendChild(generatedInstance); const generated = document.implementation.createDocument(null, 'data', null); // const newData = this._generateInstance(this, generated.firstElementChild); this._generateInstance(this, generated.firstElementChild); generatedInstance.instanceData = generated; model.instances.push(generatedInstance); // console.log('generatedInstance ', this.getModel().getDefaultInstanceData()); Fore.dispatch(this, 'instance-loaded', { instance: this }); } } catch (e) { console.warn( 'lazyCreateInstance created an error attempting to create a document', e.message, ); } } /** * @param {Element} start * @param {Element} parent */ _generateInstance(start, parent) { if (start.hasAttribute('ref') && !Fore.isActionElement(start.nodeName)) { const ref = start.getAttribute('ref'); if (ref.includes('/')) { // console.log('complex path to create ', ref); const steps = ref.split('/'); steps.forEach(step => { // const generated = document.createElement(ref); parent = this._generateNode(parent, step, start); }); } else { parent = this._generateNode(parent, ref, start); } } if (start.hasChildNodes()) { const list = start.children; for (let i = 0; i < list.length; i += 1) { this._generateInstance(list[i], parent); } } return parent; } // eslint-disable-next-line class-methods-use-this _generateNode(parent, step, start) { const generated = parent.ownerDocument.createElement(step); if (start.children.length === 0) { generated.textContent = start.textContent; } parent.appendChild(generated); parent = generated; return parent; } /** * Start the initialization of the UI by * * 1. checking if a instance needs to be generated * 2. attaching lazy loading intersection observers if `refresh-on-view` attributes are found * 3. doing a full refresh of the UI * * @returns {Promise<void>} * @private */ async _initUI() { console.info( `%cinitUI #${this.id}`, 'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); const parentFore = this.closest('fx-fore'); if (parentFore) { this.initialRun = false; } else { if (!this.initialRun) return; } this.classList.add('initialRun'); await this._lazyCreateInstance(); /* const options = { root: null, rootMargin: '0px', threshold: 0.3, }; */ // First refresh should be forced if (this.createNodes) { this.initData(); const binds = this.getModel().querySelector('fx-bind'); if (binds) { this.getModel().updateModel(); } } // await this.forceRefresh(); await this.refresh(true); // await Fore.initUI(this); // this.style.display='block' this.classList.add('fx-ready'); document.body.classList.add('fx-ready'); this.ready = true; this.initialRun = false; // console.log('### >>>>> dispatching ready >>>>>', this); console.info( `%c βœ… ${this.id ? '#' + this.id : 'Fore'} is ready`, 'background:lightgreen; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); // console.log(`### <<<<< ${this.id} ready >>>>>`); // console.log('### modelItems: ', this.getModel().modelItems); Fore.dispatch(this, 'ready', {}); // console.log('dataChanged', FxModel.dataChanged); this.markAsClean(); this.addEventListener('dragstart', this._handleDragStart); // this.addEventListener('dragend', this._handleDragEnd); this.handleDrop = event => this._handleDrop(event); this.ownerDocument.body.addEventListener('drop', this.handleDrop); this.ownerDocument.body.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; }); } /** * @summary * Find the reference node (the future previous sibling) for a newly created element. * * @description This works in two passes: if there is a bind available for both the parent and the * child, it determines where to insert based on those binds: after an element matching the previous bind in document order, before the next sibling of that one cause `insertBefore` is easier . * * For example, take this structure: * ```html * <fx-bind ref="root"> * <fx-bind ref="a" /> * <fx-bind ref="b" /> * <fx-bind ref="c" /> * </fx-bind> * ``` * Inserting a `<b/>`, it will be inserted before a `<c/>`, or at the end. Whatever comes after the `<a/>`. * * If there are no binds, the previous bound element will be used to determine the location. * @private * * @param {Element} newElement - The newly created element * @param {ParentNode} parentElement - The parent under which the element will be inserted * @param {import('./ForeElementMixin.js').default} previousControl - The previous control. Will * be used to determine a fallback to snert the element under if there are no binds for the parent * * @returns {ChildNode} */ _findReferenceNodeForNewElement(newElement, parentElement, previousControl) { const bindForElement = this.model.getModelItem(parentElement)?.bind; if (!bindForElement) { // Parent is unbound. No clue what to do with this. Insert based on previous control let referenceNode = previousControl?.getModelItem()?.node; // We know which node to insert this new element to, but it might be a descendant of a child // of the actual parent. Walk up until we have a reference under our parent while (referenceNode?.parentNode && referenceNode?.parentNode !== parentElement) { referenceNode = referenceNode.parentNode; } if (referenceNode?.nodeType === Node.ATTRIBUTE_NODE) { // Insert the new node at the start: the previous control was an attribute return null; } return referenceNode; } // Temporarily insert the new element under the parent to see which XPath will match try { parentElement.appendChild(newElement); const bindForElement = this.model.getBindForElement(newElement); if (bindForElement) { // There is a bind for this element! Insert the new element after the last element that // matched in the preceding fx-bind /* * Assumes a bind structure like this: * * ```xml * <fx-bind ref="root"> * <fx-bind ref="a" /> * <fx-bind ref="b" /> * </fx-bind> * ``` * * It will then attempt to keep all `b` elements after all `a` elements. */ /** * @type {FxBind} */ const previousBind = bindForElement.previousElementSibling; if (previousBind) { /** * @type ChildNode[]} */ const nodeset = previousBind.nodeset; const lastMatchingSibling = nodeset.reverse().find(node => parentElement.contains(node)); if (lastMatchingSibling) { return lastMatchingSibling; } // Otherwise, just default to appending... If this runs multiple times for multiple nodes // it's unexpected to always prepend and get the order of children reversed from the UI. // Do not fall back on the UI here, just keep it predictable if binds are in play return parentElement.lastElementChild; } } } finally { newElement.remove(); } // No clue. Insert based on previous control. We know which node to insert this new element // into, but it might be a descendant of a child of the actual parent. Walk up until we have a // reference under our parent let referenceNode = previousControl?.getModelItem()?.node; while (referenceNode?.parentNode && referenceNode?.parentNode !== parentElement) { referenceNode = referenceNode.parentNode; } if (referenceNode?.nodeType === Node.ATTRIBUTE_NODE) { // Insert the new node at the start: the previous control was an attribute return null; } // Insert after the previous control return referenceNode; } /** * @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items * */ initData(root = this) { // const created = new Promise(resolve => { // console.log('INIT'); // const boundControls = Array.from(root.querySelectorAll('[ref]:not(fx-model *),fx-repeatitem')); /** * @type {import('./ForeElementMixin.js').default[]} */ const boundControls = Array.from( root.querySelectorAll( 'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref]', ), ); if (root.matches && root.matches('fx-repeatitem')) { boundControls.unshift(root); } console.log('_initData', boundControls); for (let i = 0; i < boundControls.length; i++) { const bound = boundControls[i]; /* ignore bound elements that are enclosed with a control like <select> or <fx-items> and repeated items */ if (!bound.matches('fx-repeatitem') && !bound.parentNode.closest('fx-control')) { // Repeat items are dumb. They do not respond to evalInContext bound.evalInContext(); } if (b