| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491 |
- /*!
- * 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<Element, Element>} */
- var templates = new WeakMap();
- /** Instance nodes managed by a loop holder. @type {WeakMap<Element, Element[]>} */
- 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); });
- })();
|