/*! * Statum - client-side state library (document module). * * The render walk: expands `stm-for` loops and `stm-if` chains, applies the * structural and DOM-level `stm-*` bindings (`stm-class.*`, * `stm-attribute.*`, `stm-disabled`, `stm-text`, `stm-html`), wires * `stm-action` elements, and recurses into child elements. The bindings * register through core's attribute seam and this module installs core's * render driver, so `renderAll` drives the walk defined here. The * structural attributes (`stm-for`, `stm-if`/`stm-else-if`/`stm-else` and * `stm-def-*`) stay hardcoded in the walk: they build the scope and the * element list every handler receives. * * Load order: SECOND - requires statum-core.js, which creates `window.statum` * and the `_bridge` this module registers through. Like core: an IIFE, no * dependencies, no build step, plain script tag. */ (function () { 'use strict'; if (!window.statum || !window.statum._bridge) { console.error('[statum] statum-document.js requires statum-core.js'); return; } // Core machinery shared through the module seam (see statum-core.js). // Aliased so the moved code keeps its original call shapes. var bridge = window.statum._bridge; var evalExpr = bridge.evalExpr; var childScope = bridge.childScope; var stateScope = bridge.stateScope; var triggerConfirm = bridge.triggerConfirm; var performAction = bridge.performAction; var applyErrorClasses = bridge.applyErrorClasses; // --------------------------------------------------------------------------- // Rendering: loops, conditionals, bindings // --------------------------------------------------------------------------- /** Templates captured from loop holders. @type {WeakMap} */ var templates = new WeakMap(); /** Instance nodes managed by a loop holder. @type {WeakMap} */ var instancesByHolder = new WeakMap(); /** Loop instance nodes; skipped by the render walk (owned by their holder). */ var instanceNodes = new WeakSet(); /** Elements whose `stm-action` has been wired, to avoid double-binding. */ var wired = new WeakSet(); /** * Find a `stm-for-{name}-in` attribute on an element. * @returns {{varName: string, expr: string}|null} */ function getStmFor(el) { var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { var match = attrs[i].name.match(/^stm-for-(.+)-in$/); if (match) return { varName: match[1], expr: attrs[i].value }; } return null; } /** Remove any `stm-for-*` and `stm-key` attributes from an element. */ function stripLoopAttrs(el) { var toRemove = []; for (var i = 0; i < el.attributes.length; i++) { var name = el.attributes[i].name; if (/^stm-for-(.+)-in$/.test(name) || name === 'stm-key') toRemove.push(name); } toRemove.forEach(function (n) { el.removeAttribute(n); }); } /** * Evaluate an `stm-if` / `stm-else-if` / `stm-else` chain beginning at `el`, * showing the matching branch and hiding the rest. * * Branches are toggled with the `hidden` attribute rather than detached, so * every branch stays in the DOM and is re-evaluated on each render. (Detaching * the `stm-if` element would remove it from the render walk, leaving an * already-attached `stm-else` branch skipped and never refreshed.) * @returns {Element|null} The active branch element (or `null`). */ function handleIfChain(el, scope) { var branches = [{ el: el, expr: el.getAttribute('stm-if') }]; var cur = el.nextElementSibling; while (cur) { if (cur.hasAttribute('stm-else-if')) { branches.push({ el: cur, expr: cur.getAttribute('stm-else-if') }); cur = cur.nextElementSibling; } else if (cur.hasAttribute('stm-else')) { branches.push({ el: cur, expr: null }); break; } else { break; } } var activeEl = null; for (var i = 0; i < branches.length; i++) { var b = branches[i]; if (b.expr === null || !!evalExpr(b.expr, scope)) { activeEl = b.el; break; } } branches.forEach(function (b) { if (b.el === activeEl) b.el.removeAttribute('hidden'); else b.el.setAttribute('hidden', ''); }); return activeEl; } /** Toggle `stm-class.{name}` classes from their predicate expressions. */ function handleClass(el, scope) { var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { var attr = attrs[i]; if (attr.name.indexOf('stm-class.') === 0) { var cls = attr.name.slice('stm-class.'.length); if (cls && !!evalExpr(attr.value, scope)) el.classList.add(cls); else el.classList.remove(cls); } } } /** * Bind `stm-attribute.{name}`: a boolean result toggles the attribute, any * other defined value sets it, and `undefined` removes it. * * For booleans, prefer the element's IDL property when one exists (e.g. * `checked`, `disabled`, `hidden`) — setting the content attribute is * unreliable for these once the control is "dirty". */ function handleAttribute(el, scope) { var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { var attr = attrs[i]; if (attr.name.indexOf('stm-attribute.') === 0) { var name = attr.name.slice('stm-attribute.'.length); if (!name) continue; var value = evalExpr(attr.value, scope); if (value === undefined) { el.removeAttribute(name); } else if (typeof value === 'boolean') { if (name in el && typeof el[name] === 'boolean') el[name] = value; else if (value) el.setAttribute(name, ''); else el.removeAttribute(name); } else { el.setAttribute(name, value === null ? '' : String(value)); } } } } /** * Apply `stm-disabled`: when the predicate is truthy, disable the element and * its descendant form controls; otherwise enable them. */ function handleDisabled(el, scope) { if (!el.hasAttribute('stm-disabled')) return; setDisabledCascade(el, !!evalExpr(el.getAttribute('stm-disabled'), scope)); } /** Set the `disabled` attribute on an element and its descendant controls. */ function setDisabledCascade(el, on) { var nodes = [el].concat(Array.prototype.slice.call( el.querySelectorAll('button,input,select,textarea,fieldset'))); nodes.forEach(function (n) { if (on) n.setAttribute('disabled', ''); else n.removeAttribute('disabled'); }); } // --------------------------------------------------------------------------- // stm-text / stm-html // --------------------------------------------------------------------------- // (stm-markdown and stm-h-demotion moved to statum-markdown.js and register // through the same seam, ordered after `stm-html`.) /** * Bind `stm-text`. Per the "leave original DOM content" policy, an * unresolved value (`undefined`) leaves the element untouched; otherwise * the value is stringified (`null` becomes empty). */ function handleText(el, scope) { if (el.hasAttribute('stm-text')) { var text = evalExpr(el.getAttribute('stm-text'), scope); if (text !== undefined) el.textContent = text === null ? '' : String(text); } } /** * Bind `stm-html`, with the same policy as `stm-text`. */ function handleHtml(el, scope) { if (el.hasAttribute('stm-html')) { var html = evalExpr(el.getAttribute('stm-html'), scope); if (html !== undefined) el.innerHTML = html === null ? '' : String(html); } } /** * Expand a loop holder: reconcile its instances against the current array. * Keyed loops (`stm-key`) reuse DOM nodes for unchanged keys to preserve * focus/selection/input state; unkeyed loops rebuild wholesale. */ function handleLoop(el, scope) { var info = getStmFor(el); if (!info) return; var template = templates.get(el); if (!template) { template = el.cloneNode(true); stripLoopAttrs(template); templates.set(el, template); } var raw = evalExpr(info.expr, scope); if (raw === undefined) { // No value for the source path yet: clear instances and restore the // server-rendered holder content (progressive enhancement). var prev = instancesByHolder.get(el) || []; prev.forEach(function (n) { n.remove(); }); instancesByHolder.set(el, []); if (el.getAttribute('data-stm-active') === '1') { el.removeAttribute('data-stm-active'); el.removeAttribute('hidden'); } return; } var arr = Array.isArray(raw) ? raw : []; // Activate: hide the holder so only rendered instances are visible. if (el.getAttribute('data-stm-active') !== '1') { el.setAttribute('data-stm-active', '1'); el.setAttribute('hidden', ''); } var keyExpr = el.getAttribute('stm-key'); var next = []; if (keyExpr) { var oldByKey = new Map(); (instancesByHolder.get(el) || []).forEach(function (n) { if (n.__stmKey !== undefined) oldByKey.set(n.__stmKey, n); }); var used = new Set(); arr.forEach(function (item) { var child = childScope(scope, info.varName, item); var key = evalExpr(keyExpr, child); var node = (key !== undefined && !used.has(key)) ? oldByKey.get(key) : undefined; if (node) { used.add(key); processElement(node, child); } else { node = template.cloneNode(true); node.__stmKey = key; instanceNodes.add(node); processElement(node, child); } next.push(node); }); oldByKey.forEach(function (n, key) { if (!used.has(key)) n.remove(); }); } else { (instancesByHolder.get(el) || []).forEach(function (n) { n.remove(); }); arr.forEach(function (item) { var child = childScope(scope, info.varName, item); var node = template.cloneNode(true); instanceNodes.add(node); processElement(node, child); next.push(node); }); } // Place instances in order, immediately after the holder. var ref = el; next.forEach(function (node) { if (node !== ref.nextSibling) ref.parentNode.insertBefore(node, ref.nextSibling); ref = node; }); instancesByHolder.set(el, next); } /** * Process one element's bindings (and recurse). Loop holders are handled by * {@link handleLoop}; `stm-else-if` / `stm-else` are owned by their `stm-if`. */ function processElement(el, scope) { if (getStmFor(el)) { handleLoop(el, scope); return; } if (el.hasAttribute('stm-else-if') || el.hasAttribute('stm-else')) return; if (el.hasAttribute('stm-if')) { var active = handleIfChain(el, scope); if (active) processInner(active, scope); return; } processInner(el, scope); } /** * Collect `stm-def-{name}-as` attributes on an element into a child scope. * Each expression is evaluated against the incoming scope (so the new * variables cannot reference each other within the same element), and the * results are added to a child scope visible to the element and its * descendants. Multiple defs on one element are supported. If there are no * defs, the original scope is returned unchanged. * @param {Element} el * @param {object} scope * @returns {object} */ function applyDefs(el, scope) { var attrs = el.attributes; var child = null; for (var i = 0; i < attrs.length; i++) { var name = attrs[i].name; if (name.indexOf('stm-def-') === 0 && name.slice(-3) === '-as') { var varName = name.slice(8, -3); if (!varName) continue; if (!child) child = Object.create(scope || null); child[varName] = evalExpr(attrs[i].value, scope); } } return child || scope; } /** * Bind the non-control-flow attributes and recurse into children. * * Handlers run in the seam's resolved order (see registerAttribute in * statum-core.js): once all modules have loaded it is stm-class.* -> * stm-attribute.* -> stm-disabled -> stm-text -> stm-html -> * stm-markdown -> stm-h-demotion, the order this walk used when it called * the handlers directly. A throwing handler aborts the render cycle via * renderAll's try/catch, as before. */ function processInner(el, scope) { var s = applyDefs(el, scope); // local definitions: element + descendants el.__stmScope = s; // latest scope, used when an action fires var registered = bridge.attributeHandlers; for (var i = 0; i < registered.length; i++) { var h = registered[i]; if (h.prefix ? hasAttrPrefix(el, h.name) : el.hasAttribute(h.name)) h.fn(el, s); } wireAction(el); render(el, s); } /** Whether `el` carries any attribute starting with `prefix`. */ function hasAttrPrefix(el, prefix) { var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { if (attrs[i].name.indexOf(prefix) === 0) return true; } return false; } /** * Walk an element's children, processing each. Loop instance nodes are * skipped here (they are owned and processed by their holder). */ function render(root, scope) { if (!root || !root.children) return; // Snapshot the children: processing mutates the DOM (detaching branches, // inserting loop instances) and a live HTMLCollection would shift indices. var children = Array.prototype.slice.call(root.children); for (var i = 0; i < children.length; i++) { var el = children[i]; if (instanceNodes.has(el)) continue; processElement(el, scope); } } // --------------------------------------------------------------------------- // HTML API: actions // --------------------------------------------------------------------------- /** * Collect data for an action trigger: `stm-data-{key}` attributes plus, for a * form, its named inputs. * @param {HTMLElement} el * @returns {object} */ function collectData(el) { var data = {}; var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { var name = attrs[i].name; if (name.indexOf('stm-data-') === 0) data[name.slice('stm-data-'.length)] = attrs[i].value; } if (el.tagName === 'FORM') { var fd = new FormData(el); fd.forEach(function (value, key) { data[key] = value instanceof File ? value.name : String(value); }); } return data; } /** * Toggle the busy state of an action element: disables the element and its * descendant form controls and applies `stm-busy-class` classes while busy. * * On un-busy the disabled state is not "restored" to a captured snapshot * (that would clobber a `stm-disabled` value that `renderAll` applied during * the response); instead the nodes are re-enabled and `stm-disabled` is * re-evaluated, which re-disables any that should remain disabled. */ function setBusyState(el, busy) { var nodes = [el].concat(Array.prototype.slice.call( el.querySelectorAll('button,input,select,textarea,fieldset'))); var busyClasses = (el.getAttribute('stm-busy-class') || '').split(/\s+/).filter(Boolean); if (busy) { nodes.forEach(function (n) { n.setAttribute('disabled', ''); }); busyClasses.forEach(function (c) { el.classList.add(c); }); } else { nodes.forEach(function (n) { n.removeAttribute('disabled'); }); busyClasses.forEach(function (c) { el.classList.remove(c); }); reapplyDisabled(el); } } /** Re-apply `stm-disabled` to an element and any descendants that carry it. */ function reapplyDisabled(el) { var scope = el.__stmScope || stateScope(); if (el.hasAttribute('stm-disabled')) handleDisabled(el, scope); var scoped = el.querySelectorAll('[stm-disabled]'); Array.prototype.forEach.call(scoped, function (n) { handleDisabled(n, n.__stmScope || scope); }); } /** * Wire an element's primary action once. The listener resolves the action * object against the element's latest render scope, so loop instances bind to * their own item. */ function wireAction(el) { if (!el.hasAttribute('stm-action') || wired.has(el)) return; wired.add(el); var eventName = el.tagName === 'FORM' ? 'submit' : 'click'; el.addEventListener(eventName, function (event) { event.preventDefault(); runElementAction(el); }); } /** * Resolve and perform the action attached to an element, managing busy and * error-state classes around the request. * @param {HTMLElement} el */ async function runElementAction(el) { var scope = el.__stmScope || stateScope(); var action = evalExpr(el.getAttribute('stm-action'), scope); if (!action || typeof action.uri !== 'string') return; // stm-confirm: gate the action behind the confirm handler (async-capable, // so custom modals are supported). if (el.hasAttribute('stm-confirm')) { var confirmed = await triggerConfirm(el.getAttribute('stm-confirm'), el); if (!confirmed) return; } applyErrorClasses(el, false); // clear error state on a new attempt var data = collectData(el); setBusyState(el, true); try { await performAction(action, data, el); } catch (err) { // Already handled: stm-on-error classes and the onErrorHandler run inside // performAction/reportFailure. Swallow so an HTML-triggered action does // not surface as an unhandled rejection. } finally { setBusyState(el, false); } } // --------------------------------------------------------------------------- // Registration (module seam) // --------------------------------------------------------------------------- // Registration order mirrors the order these handlers had when processInner // called them directly; statum-markdown.js constrains its own handlers // relative to `stm-html`. bridge.registerAttribute('stm-class.', handleClass); bridge.registerAttribute('stm-attribute.', handleAttribute); bridge.registerAttribute('stm-disabled', handleDisabled); bridge.registerAttribute('stm-text', handleText); bridge.registerAttribute('stm-html', handleHtml); // Core's renderAll drives the walk defined in this module. bridge.installRenderDriver(function (scope) { render(document.body, scope); }); })();