| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052 |
- /*!
- * 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` `<body>` 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<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 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 `<link rel="stm-guard">` 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<Element>}
- */
- 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.
- * - `<input type=file>` 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 `<pstm-*>` 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`
- * `<body>` 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.
- * `<div stm-content hidden>`); the library removes it once ready. The library
- * also sets `hidden` itself during loading as a fallback for authors who do
- * not pre-set it.
- * @param {boolean} ready
- */
- function setBootState(ready) {
- var preloaders = document.querySelectorAll('[stm-preloader]');
- var contents = document.querySelectorAll('[stm-content]');
- for (var i = 0; i < preloaders.length; i++) {
- ready ? preloaders[i].setAttribute('hidden', '') : preloaders[i].removeAttribute('hidden');
- }
- for (var j = 0; j < contents.length; j++) {
- ready ? contents[j].removeAttribute('hidden') : contents[j].setAttribute('hidden', '');
- }
- }
- // ---------------------------------------------------------------------------
- // Installation
- // ---------------------------------------------------------------------------
- // Registration order mirrors the order these handlers had when processInner
- // called them directly; stm-prefill (from the former forms module) is first
- // so its property writes feed the controls before any class/attribute
- // predicate evaluates; statum-markdown.js constrains its own handlers
- // relative to `stm-html`.
- registerAttribute('stm-prefill', handlePrefill);
- registerAttribute('stm-class.', handleClass);
- registerAttribute('stm-attribute.', handleAttribute);
- registerAttribute('stm-disabled', handleDisabled);
- registerAttribute('stm-text', handleText);
- registerAttribute('stm-html', handleHtml);
- // The registry itself becomes the seam other modules register through.
- // First drain anything a module registered against core alone (markdown,
- // for instance, may legitimately load without this module): its
- // after-constraints name document handlers that now exist, so they resolve
- // properly instead of warn-and-keep-position.
- bridge.takePendingRegistrations().forEach(function (reg) {
- registerAttribute(reg[0], reg[1], reg[2]);
- });
- bridge.attributeHandlers = attributeHandlers;
- bridge.registerAttribute = registerAttribute;
- window.statum.registerAttribute = registerAttribute;
- // Install the presentation hooks core calls. Core's renderAll drives the
- // walk defined here; reportFailure reaches the classes defined here; init
- // reaches the boot visibility defined here; every render cycle ends with
- // the guard evaluation defined here. The driver itself honours the guard
- // state: once a guard has redirected, walks are skipped (core's renderAll
- // no longer holds that flag — the mandate's "driver checks its own guard
- // state before walking" mechanism).
- bridge.renderDriver(function (scope) {
- if (guardRedirected) return;
- var passStart = bridge.getPassStartHook();
- if (passStart) passStart(scope); // per-pass resets + global sweeps
- render(document.body, scope);
- });
- bridge.setBootState(setBootState);
- bridge.afterRenderCycle(evaluateGuards);
- bridge.applyErrorClasses(applyErrorClasses);
- // Boot configuration must be in place before core's init() runs: the same
- // script tags that load this module also trigger core's autoInit on
- // DOMContentLoaded, and a synchronous page load has this module loaded by
- // then. (Direct statum.init() calls by embedding frameworks happen after
- // the module set loads, so the ordering holds there too.)
- readBootConfig();
- // Before the first render, so `hidden` wins over author CSS from the very
- // first paint.
- try { injectHiddenStyle(); } catch (e) {}
- try { warnServerSideMarkup(); } catch (e) {}
- })();
|