/*! * Statum - client-side state library (document module). * * Everything DOM. statum-core.js is headless (state, directives, SSE, workers, * persistence — no DOM at all); this module owns the presentation side and * installs itself into core through the module bridge at load time: * * - 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 * structural attributes (`stm-for`, `stm-if`/`stm-else-if`/`stm-else` and * `stm-local-*` / `stm-declare-*` variables) stay hardcoded in the walk: they build the scope and the * element list every handler receives. * - the attribute-handler REGISTRY and the public `statum.registerAttribute` * seam: module authors register `stm-*` handlers here; the walk iterates * them in resolved order. * - boot: reads the `stm-entrypoint` / `stm-slots` / `stm-channel` / * `stm-worker` `` attributes into `statum.config` (core's own * mutable config object — no new API), manages the `stm-preloader` / * `stm-content` boot visibility, injects the `hidden` stylesheet and * warns about leftover server-side `pstm-*` markup. * - guards: `stm-guard` links, evaluated through core's afterRenderCycle * hook (redirected pages stop rendering). * - error/success state: `stm-on-error` / `stm-on-success` classes on action * elements, applied through core's reportFailure path. * - forms (merged from the former statum-forms.js): `stm-prefill` * (state -> control PROPERTY writes with dirty-element tracking), * `stm-clear-on-success` / `stm-clear-on-error` and the `statum.document` * API. * * stm-prefill writes properties, never attributes — that is its entire point. * `stm-attribute.value` keeps writing the value ATTRIBUTE (the server-wins * default-dirty path, issue C2); stm-prefill instead updates the control's * live `.value` / `.checked` / `.selected` properties so a prefill never * fights the user's typing. Elements the user has edited (dirty elements) are * never overwritten by a later render cycle. * * Radio groups are prefilled per-element: a radio carrying stm-prefill is * checked iff its own value matches the resolved value. Give every member of * the group the same stm-prefill expression and the group behaves correctly. * * 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; // --------------------------------------------------------------------------- // Attribute-handler registry (module seam) // --------------------------------------------------------------------------- // // `_bridge` (underscore = private by convention, like `_testMarkdown`) is // the internal cross-module surface; `statum.registerAttribute` is the // public entry point module authors use. /** * Resolved execution order of the per-element `stm-*` bindings: an ordered * list of `{name, fn, prefix}`. The render walk below iterates it for every * element. A throwing handler aborts that render cycle through renderAll's * try/catch, exactly as the previous hardcoded call chain did, so handlers * get no per-handler try/catch here. * @type {Array<{name: string, fn: Function, prefix: boolean}>} */ var attributeHandlers = []; /** * Register a handler for an `stm-*` attribute. * * `name` is either an exact attribute name (`'stm-disabled'`) or, when it * ends with a dot, a prefix matcher (`'stm-class.'` matches any * `stm-class.{name}`). The handler is called once per element per render * cycle when the element carries at least one matching attribute; it then * iterates the element's attributes itself (several may match a prefix). * * `opts.after` / `opts.before` name another registered attribute and the * handler is inserted directly after / before it. A constraint naming an * unregistered attribute warns and keeps plain registration order, so * loading a module early cannot break the page. Registering the same name * twice warns and ignores the second handler. * * @param {string} name * @param {Function} fn `fn(el, scope)` * @param {{after?: string, before?: string}} [opts] */ function registerAttribute(name, fn, opts) { if (typeof name !== 'string' || !name || typeof fn !== 'function') { console.warn('[statum] registerAttribute: expected an attribute name and a handler function'); return; } for (var i = 0; i < attributeHandlers.length; i++) { if (attributeHandlers[i].name === name) { console.warn('[statum] registerAttribute: "' + name + '" is already registered; ignoring'); return; } } var entry = { name: name, fn: fn, prefix: name.charAt(name.length - 1) === '.' }; var anchor = opts && (opts.after || opts.before); var at = anchor ? handlerIndex(anchor) : -1; if (anchor && at === -1) { console.warn('[statum] registerAttribute: "' + anchor + '" is not registered; keeping registration order for "' + name + '"'); } if (at !== -1 && opts.after) at += 1; // insert directly after the anchor if (at === -1) attributeHandlers.push(entry); else attributeHandlers.splice(at, 0, entry); } /** Position of a registered handler by name, or -1. */ function handlerIndex(name) { for (var i = 0; i < attributeHandlers.length; i++) { if (attributeHandlers[i].name === name) return i; } return -1; } // --------------------------------------------------------------------------- // 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 live in 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); } /** * Construct the element's effective scope. Local variable bindings * (stm-local-*, stm-declare-* — statum-binding.js, which may be absent) * attach through the bridge's scope-construction seam; without that * module the incoming scope passes through unchanged. */ function buildScopeFor(el, scope) { var builder = bridge.getScopeBuilder(); return builder ? builder(el, scope) : scope; } /** * Run the registered bindings on one element. The resolved handler order * (registration + after/before constraints) replaces the previous hardcoded * call chain: a throwing handler aborts that render cycle via renderAll's * try/catch, as before. */ function processInner(el, scope) { var s = buildScopeFor(el, scope); // local variables (binding module) el.__stmScope = s; // latest scope, used when an action fires for (var i = 0; i < attributeHandlers.length; i++) { var h = attributeHandlers[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(); // Stash the action promise so stm-clear-on-success / // stm-clear-on-error (below) can await it and reset the form only on // the matching outcome. el.__stmAction = runElementAction(el); }); } /** * Resolve and perform the action attached to an element, managing busy and * error/success-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') { // Unresolvable action: nothing was requested, so neither outcome // applies (cancellation, not error — a reset would eat the user's // input for a no-op). el.__stmActionFailed = true; el.__stmActionCancelled = true; return; } // Outcome flags read by stm-clear-on-success / stm-clear-on-error (below). // The returned promise resolves even on failure (the catch below swallows // the rejection so it does not surface as unhandled), so success needs // its own signal. Pessimistic: `failed` stays true until the action // completes successfully, so a declined confirm or an unresolvable // action never reads as success. `cancelled` marks the paths where no // request was ever made (unresolvable action, declined stm-confirm): // decline is cancellation, not error, so stm-clear-on-error must not // fire there either. el.__stmActionFailed = true; el.__stmActionCancelled = false; // 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) { el.__stmActionCancelled = true; return; } } applyErrorClasses(el, false); // clear error/success state on a new attempt var data = collectData(el); setBusyState(el, true); var failed = false; try { await performAction(action, data, el); applyErrorClasses(el, 'success'); // success classes mirror the error ones } catch (err) { failed = true; // 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 { el.__stmActionFailed = failed; setBusyState(el, false); } } // --------------------------------------------------------------------------- // Error/success-state classes (installed into core's reportFailure path) // --------------------------------------------------------------------------- /** * Apply or remove the `stm-on-error` / `stm-on-success` classes on an action * element. The two are mirrored: a failure applies the error classes and * clears the success classes; a success applies the success classes and * clears the error classes; a new attempt (or the no-attribute case) clears * both. * * Installed into core as `bridge.applyErrorClasses` so core's * reportFailure stays self-contained without knowing about classes; the * walk's own new-attempt call site (runElementAction) clears both. * @param {HTMLElement} el * @param {boolean|'success'} on `true` applies the error classes (and * clears success); `'success'` applies the success classes (and clears * error); `false` clears both. */ function applyErrorClasses(el, on) { if (!el || !el.hasAttribute) return; if (!el.hasAttribute('stm-on-error') && !el.hasAttribute('stm-on-success')) return; var errClasses = el.hasAttribute('stm-on-error') ? (el.getAttribute('stm-on-error') || '').split(/\s+/).filter(Boolean) : []; var okClasses = el.hasAttribute('stm-on-success') ? (el.getAttribute('stm-on-success') || '').split(/\s+/).filter(Boolean) : []; var mode = on === 'success' ? 'success' : (on ? 'error' : 'clear'); errClasses.forEach(function (c) { if (mode === 'error') el.classList.add(c); else el.classList.remove(c); }); okClasses.forEach(function (c) { if (mode === 'success') el.classList.add(c); else el.classList.remove(c); }); } // --------------------------------------------------------------------------- // Guards (stm-guard links) — installed into core's afterRenderCycle hook // --------------------------------------------------------------------------- /** Set when a guard has triggered a redirect, so further work is skipped. */ var guardRedirected = false; /** * Evaluate every `` in the document head. A guard whose * `require` predicate is false redirects the page to its `href`. * * Guards marked `pre-entrypoint` are evaluated on every cycle (including the * initial, pre-entrypoint render, which uses only hydrated state); guards * without it are evaluated only once the entrypoint has loaded. On every later * evaluate cycle (set/clear) all guards are re-checked, so logging out, for * example, can bounce the user off a guarded page. */ function evaluateGuards() { if (guardRedirected) return; var scope = stateScope(); var guards = document.querySelectorAll('link[rel~="stm-guard"]'); for (var i = 0; i < guards.length; i++) { var g = guards[i]; var preEntrypoint = g.hasAttribute('pre-entrypoint'); if (!preEntrypoint && !bridge.entrypointLoaded()) continue; var require = g.getAttribute('require'); if (!require) continue; if (!evalExpr(require, scope)) { guardRedirected = true; var href = g.getAttribute('href') || '/'; console.warn('[statum] guard failed (' + require + '); redirecting to ' + href); window.location.href = href; return; } } } // --------------------------------------------------------------------------- // Forms (merged from the former statum-forms.js) // --------------------------------------------------------------------------- // stm-prefill / dirty tracking / stm-clear-on-success / stm-clear-on-error, // all hosted here; the public API is `statum.document`. /** * Elements the user has edited since they were last reset. A dirty element * is skipped by stm-prefill: the user's edit outranks the state snapshot. * * Hosted on the bridge (not module-private) so task 186's reconciliation * work can reuse the same set instead of guessing dirtiness from DOM * comparison. Use `bridge.markDirty` / `bridge.isDirty`; do not poke the * WeakSet directly. * @type {WeakSet} */ var formsDirty = new WeakSet(); bridge.formsDirty = formsDirty; /** Mark an element dirty. @param {Element} el */ function markDirty(el) { formsDirty.add(el); } /** Whether an element is dirty. @param {Element} el @returns {boolean} */ function isDirty(el) { return formsDirty.has(el); } bridge.markDirty = markDirty; bridge.isDirty = isDirty; /** * Un-dirty a form and all its form controls (the form element itself is * rarely dirty — its inputs are — but clearing both is harmless). * @param {Element} form */ function unmarkForm(form) { formsDirty.delete(form); var nodes = form.querySelectorAll('button,input,select,textarea,fieldset'); for (var i = 0; i < nodes.length; i++) formsDirty.delete(nodes[i]); } // --------------------------------------------------------------------------- // Document-level listeners (capture phase) // --------------------------------------------------------------------------- /** Mark the element an input/change event targeted. @param {Event} ev */ function onEdit(ev) { var t = ev.target; if (t && t.nodeType === 1) markDirty(t); } /** * The reset event fires BEFORE the browser restores default values (it is * cancelable), so un-dirtying during reset is correct: the values become * the defaults — clean, as far as statum is concerned — and the next * prefill cycle can re-apply state. * * Double duty: the server's `form-reset` directive calls form.reset(), * which fires this same reset event, so the directive un-dirties forms * through this one listener too. * @param {Event} ev */ function onReset(ev) { var f = ev.target; if (f && f.nodeType === 1 && f.tagName === 'FORM') unmarkForm(f); } document.addEventListener('input', onEdit, true); document.addEventListener('change', onEdit, true); document.addEventListener('reset', onReset, true); // --------------------------------------------------------------------------- // stm-prefill // --------------------------------------------------------------------------- /** Input types whose value is plain text (property `value`). */ var TEXTLIKE_TYPES = [ 'text', 'email', 'number', 'password', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'week', 'time', 'range', 'color', 'hidden' ]; /** * Bind `stm-prefill`: write the resolved expression into the control's * properties (never attributes). * * Semantics: * - Dirty elements are skipped entirely — the user's edit outranks state. * - `` is skipped entirely: assigning `el.value` from a * script is forbidden by the platform (and silently no-ops), so there is * nothing sensible to write. No per-element warning; the file-wide * "unresolved leaves the DOM alone" policy applies. * - `undefined` (unresolved expression) leaves the element untouched. * - `null` is written as `''` for value writes and `false` for checked * writes. * - Runs every render cycle like every handler; property writes are * naturally idempotent per cycle. * * @param {Element} el * @param {object} scope */ function handlePrefill(el, scope) { if (isDirty(el)) return; var type = el.type || (el.tagName === 'INPUT' ? 'text' : ''); if (el.tagName === 'INPUT' && type === 'file') return; var value = evalExpr(el.getAttribute('stm-prefill'), scope); if (value === undefined) return; if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && TEXTLIKE_TYPES.indexOf(type) !== -1)) { el.value = value == null ? '' : String(value); return; } if (el.tagName === 'INPUT' && (type === 'checkbox')) { el.checked = !!value; return; } if (el.tagName === 'INPUT' && type === 'radio') { // Per-element: see the module header. A group is prefilled by giving // its members the same stm-prefill expression. el.checked = (el.value === String(value)); return; } if (el.tagName === 'SELECT') { if (type === 'multiple' || el.multiple === true) { var arr = Array.isArray(value) ? value : [value]; var options = el.querySelectorAll('option'); for (var i = 0; i < options.length; i++) { var opt = options[i]; opt.selected = arr.indexOf(String(opt.value)) !== -1; } } else { el.value = value == null ? '' : String(value); } return; } // Other elements (buttons, fieldsets, ...): the attribute is unsupported; // leave the element untouched rather than inventing a semantic. } // --------------------------------------------------------------------------- // stm-clear-on-success / stm-clear-on-error // --------------------------------------------------------------------------- /** Forms already warned about a missing stm-action. @type {WeakSet} */ var warnSeen = new WeakSet(); /** Warn once about a clear-on-* attribute on a form without stm-action. */ function warnNoAction(f, attr) { if (warnSeen.has(f)) return; warnSeen.add(f); console.warn('[statum] ' + attr + ' on a form without stm-action does nothing; ' + 'put stm-action on the form itself'); } /** * Reset a form when its own stm-action settles on the matching outcome. * Registered on the document (capture phase) so it sees every submit; the * promise is stashed by wireAction's target-phase listener on the form * itself (el.__stmAction — see above). * * The stash is read in a MICROTASK: this capture-phase listener runs before * the form's target-phase listener has stashed the CURRENT action promise, * so a synchronous read would see the previous submit's promise (or none). * Event dispatch completes synchronously, so by the time the microtask runs * the stash always holds this submit's promise. * * `stm-clear-on-success` and `stm-clear-on-error` are supported on a FORM * whose own stm-action performs the request (wireAction wires a FORM for * `submit`). If the form carries the attribute but no stm-action of its * own, there is no promise to await, so the attribute is inert there; warn * once per form. * * A DECLINED stm-confirm never clears: decline is cancellation, not error. * runElementAction resolves (never rejects) for declines, and marks the * outcome `cancelled` (`__stmActionCancelled === true`), which this * success-path check excludes. The reject handler below is only reached on * a real request failure, where `__stmActionFailed === true` by * construction. * * @param {Event} ev */ function onSubmit(ev) { var f = ev.target; if (!f || f.nodeType !== 1 || f.tagName !== 'FORM') return; var onSuccess = f.hasAttribute && f.hasAttribute('stm-clear-on-success'); var onError = f.hasAttribute && f.hasAttribute('stm-clear-on-error'); if (!onSuccess && !onError) return; Promise.resolve().then(function () { var promise = f.__stmAction; if (!promise) { if (onSuccess) warnNoAction(f, 'stm-clear-on-success'); if (onError) warnNoAction(f, 'stm-clear-on-error'); return; } // runElementAction RESOLVES even when the action failed (it swallows // the rejection so it does not surface as unhandled), so both outcomes // are read from its flags on the resolve path: success clears with // __stmActionFailed === false, failure with === true (and not // cancelled — a declined confirm is neither). The reject handler is // defensive only; by construction the promise never rejects. promise.then(function () { if (f.__stmActionCancelled === true) return; // decline: no request, no clear if (onSuccess && f.__stmActionFailed === false) f.reset(); else if (onError && f.__stmActionFailed === true) f.reset(); }, function () { if (onError && f.__stmActionFailed === true && f.__stmActionCancelled !== true) { f.reset(); } }); }); } document.addEventListener('submit', onSubmit, true); // --------------------------------------------------------------------------- // Programmatic API // --------------------------------------------------------------------------- /** * Resolve the form an element belongs to: the element itself if it is a * form, `el.form` where available (form controls), else the closest form * ancestor. * @param {Element} el * @returns {Element|null} */ function resolveForm(el) { if (!el) return null; if (el.tagName === 'FORM') return el; if (el.form) return el.form; if (typeof el.closest === 'function') return el.closest('form'); return null; } /** Reset exactly one form (the reset event un-dirties it). @param {Element} formOrEl */ function resetForm(formOrEl) { var f = resolveForm(formOrEl); if (f) f.reset(); } window.statum.document = { /** Reset one form (or the form owning an element). */ reset: resetForm, /** Mark an element dirty (skipped by stm-prefill until reset). */ markDirty: markDirty, /** Whether an element is dirty. */ isDirty: isDirty }; // --------------------------------------------------------------------------- // Boot (moved from core): body-attribute config, boot visibility, styles // --------------------------------------------------------------------------- /** * Inject the stylesheet that makes the `hidden` attribute authoritative. * Statum toggles `stm-if`/`stm-else` branches and loop holders via * `hidden`, whose user-agent rule loses specificity battles to ordinary * author CSS (a rule like `li { display: flex }` let hidden loop holders * leak as phantom rows). Idempotent: skipped when the stylesheet is * already present. */ function injectHiddenStyle() { var id = 'statum-hidden-style'; if (document.getElementById(id)) return; var head = document.head; if (!head) return; var style = document.createElement('style'); style.id = id; style.textContent = '[hidden]{display:none !important}'; head.appendChild(style); } /** * Scan the document for `pstm-*` attributes and `` elements, which * are intended for server-side pre-rendering and should never reach the * browser. Emits a single `console.warn` summarizing them. */ function warnServerSideMarkup() { var found = []; var els = document.querySelectorAll('*'); for (var i = 0; i < els.length; i++) { var el = els[i]; var tag = el.tagName.toLowerCase(); if (tag.indexOf('pstm-') === 0) found.push('<' + tag + '>'); var attrs = el.attributes; for (var j = 0; j < attrs.length; j++) { var name = attrs[j].name; if (name.indexOf('pstm-') === 0) found.push('<' + tag + '> ' + name); } } if (found.length) { console.warn('[statum] ' + found.length + ' server-side pstm-* attribute/element(s) ' + 'found in the DOM; they should have been resolved during pre-rendering: ' + found.slice(0, 10).join(', ') + (found.length > 10 ? ' …' : '')); } } /** * Read the `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` * `` attributes into core's config object (`statum.config`). Core's * init() then uses the config as given — it never reads the DOM itself, so * headless pages point `statum.config` at their own endpoints instead. */ function readBootConfig() { var body = document.body; if (!body) return; if (body.hasAttribute('stm-entrypoint')) window.statum.config.entrypointUrl = body.getAttribute('stm-entrypoint'); if (body.hasAttribute('stm-slots')) window.statum.config.slotsUrl = body.getAttribute('stm-slots'); if (body.hasAttribute('stm-channel')) window.statum.config.channelUrl = body.getAttribute('stm-channel'); if (body.hasAttribute('stm-worker')) window.statum.config.workerUrl = body.getAttribute('stm-worker'); } /** * Toggle pre/post-entrypoint visibility with the `hidden` attribute. While * loading, `stm-preloader` elements are shown and `stm-content` elements * hidden; once ready, the reverse. * * To avoid a flash of un-bound template before the script runs, authors may * pre-set `hidden` directly on `stm-content` in the HTML (e.g. * `