|
|
@@ -1,15 +1,44 @@
|
|
|
/*!
|
|
|
* 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.
|
|
|
+ * 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-def-*`) 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
|
|
|
@@ -31,7 +60,75 @@
|
|
|
var stateScope = bridge.stateScope;
|
|
|
var triggerConfirm = bridge.triggerConfirm;
|
|
|
var performAction = bridge.performAction;
|
|
|
- var applyErrorClasses = bridge.applyErrorClasses;
|
|
|
+
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // 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;
|
|
|
+ }
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -170,7 +267,7 @@
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// stm-text / stm-html
|
|
|
// ---------------------------------------------------------------------------
|
|
|
- // (stm-markdown and stm-h-demotion moved to statum-markdown.js and register
|
|
|
+ // (stm-markdown and stm-h-demotion live in statum-markdown.js and register
|
|
|
// through the same seam, ordered after `stm-html`.)
|
|
|
|
|
|
/**
|
|
|
@@ -298,43 +395,32 @@
|
|
|
* 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}
|
|
|
+ * descendants.
|
|
|
*/
|
|
|
function applyDefs(el, scope) {
|
|
|
+ var s = Object.create(scope || null);
|
|
|
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);
|
|
|
+ var localName = name.slice('stm-def-'.length, -3);
|
|
|
+ s[localName] = evalExpr(attrs[i].value, scope);
|
|
|
}
|
|
|
}
|
|
|
- return child || scope;
|
|
|
+ return s;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 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.
|
|
|
+ * 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 = 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];
|
|
|
+ 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);
|
|
|
@@ -437,43 +523,59 @@
|
|
|
var eventName = el.tagName === 'FORM' ? 'submit' : 'click';
|
|
|
el.addEventListener(eventName, function (event) {
|
|
|
event.preventDefault();
|
|
|
- // Stash the action promise so stm-clear-on-success (statum-forms.js)
|
|
|
- // can await it and reset the form only when the action resolved.
|
|
|
+ // 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-state classes around the request.
|
|
|
+ * 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') return;
|
|
|
+ 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 flag read by stm-clear-on-success (statum-forms.js). The
|
|
|
- // returned promise resolves even on failure (the catch below swallows
|
|
|
+ // 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: true until the action completes
|
|
|
- // successfully, so a declined confirm or an unresolvable action never
|
|
|
- // reads as success.
|
|
|
+ // 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) return;
|
|
|
+ if (!confirmed) {
|
|
|
+ el.__stmActionCancelled = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- applyErrorClasses(el, false); // clear error state on a new attempt
|
|
|
+ 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
|
|
|
@@ -485,20 +587,473 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // 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);
|
|
|
+
|
|
|
+
|
|
|
// ---------------------------------------------------------------------------
|
|
|
- // Registration (module seam)
|
|
|
+ // 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; statum-markdown.js constrains its own handlers
|
|
|
+ // 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`.
|
|
|
- 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); });
|
|
|
-})();
|
|
|
+ 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;
|
|
|
+ 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) {}
|
|
|
+})();
|