statum-document.js 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102
  1. /*!
  2. * Statum - client-side state library (document module).
  3. *
  4. * Everything DOM. statum-core.js is headless (state, directives, SSE, workers,
  5. * persistence — no DOM at all); this module owns the presentation side and
  6. * installs itself into core through the module bridge at load time:
  7. *
  8. * - the render walk: expands `stm-for` loops and `stm-if` chains, applies
  9. * the structural and DOM-level `stm-*` bindings (`stm-class.*`,
  10. * `stm-attribute.*`, `stm-disabled`, `stm-text`, `stm-html`), wires
  11. * `stm-action` elements, and recurses into child elements. The
  12. * structural attributes (`stm-for`, `stm-if`/`stm-else-if`/`stm-else` and
  13. * `stm-local-*` / `stm-declare-*` variables) stay hardcoded in the walk: they build the scope and the
  14. * element list every handler receives.
  15. * - the attribute-handler REGISTRY and the public `statum.registerAttribute`
  16. * seam: module authors register `stm-*` handlers here; the walk iterates
  17. * them in resolved order.
  18. * - boot: reads the `stm-entrypoint` / `stm-slots` / `stm-channel` /
  19. * `stm-worker` `<body>` attributes into `statum.config` (core's own
  20. * mutable config object — no new API), manages the `stm-preloader` /
  21. * `stm-content` boot visibility, injects the `hidden` stylesheet and
  22. * warns about leftover server-side `pstm-*` markup.
  23. * - guards: `stm-guard` links, evaluated through core's afterRenderCycle
  24. * hook (redirected pages stop rendering).
  25. * - error/success state: `stm-on-error` / `stm-on-success` classes on action
  26. * elements, applied through core's reportFailure path.
  27. * - forms (merged from the former statum-forms.js): `stm-prefill`
  28. * (state -> control PROPERTY writes with dirty-element tracking),
  29. * `stm-clear-on-success` / `stm-clear-on-error` and the `statum.document`
  30. * API.
  31. *
  32. * stm-prefill writes properties, never attributes — that is its entire point.
  33. * `stm-attribute.value` keeps writing the value ATTRIBUTE (the server-wins
  34. * default-dirty path, issue C2); stm-prefill instead updates the control's
  35. * live `.value` / `.checked` / `.selected` properties so a prefill never
  36. * fights the user's typing. Elements the user has edited (dirty elements) are
  37. * never overwritten by a later render cycle.
  38. *
  39. * Radio groups are prefilled per-element: a radio carrying stm-prefill is
  40. * checked iff its own value matches the resolved value. Give every member of
  41. * the group the same stm-prefill expression and the group behaves correctly.
  42. *
  43. * Load order: SECOND - requires statum-core.js, which creates `window.statum`
  44. * and the `_bridge` this module registers through. Like core: an IIFE, no
  45. * dependencies, no build step, plain script tag.
  46. */
  47. (function () {
  48. 'use strict';
  49. if (!window.statum || !window.statum._bridge) {
  50. console.error('[statum] statum-document.js requires statum-core.js');
  51. return;
  52. }
  53. // Core machinery shared through the module seam (see statum-core.js).
  54. // Aliased so the moved code keeps its original call shapes.
  55. var bridge = window.statum._bridge;
  56. var evalExpr = bridge.evalExpr;
  57. var childScope = bridge.childScope;
  58. var stateScope = bridge.stateScope;
  59. var triggerConfirm = bridge.triggerConfirm;
  60. var performAction = bridge.performAction;
  61. // ---------------------------------------------------------------------------
  62. // Attribute-handler registry (module seam)
  63. // ---------------------------------------------------------------------------
  64. //
  65. // `_bridge` (underscore = private by convention, like `_testMarkdown`) is
  66. // the internal cross-module surface; `statum.registerAttribute` is the
  67. // public entry point module authors use.
  68. /**
  69. * Resolved execution order of the per-element `stm-*` bindings: an ordered
  70. * list of `{name, fn, prefix}`. The render walk below iterates it for every
  71. * element. A throwing handler aborts that render cycle through renderAll's
  72. * try/catch, exactly as the previous hardcoded call chain did, so handlers
  73. * get no per-handler try/catch here.
  74. * @type {Array<{name: string, fn: Function, prefix: boolean}>}
  75. */
  76. var attributeHandlers = [];
  77. /**
  78. * Register a handler for an `stm-*` attribute.
  79. *
  80. * `name` is either an exact attribute name (`'stm-disabled'`) or, when it
  81. * ends with a dot, a prefix matcher (`'stm-class.'` matches any
  82. * `stm-class.{name}`). The handler is called once per element per render
  83. * cycle when the element carries at least one matching attribute; it then
  84. * iterates the element's attributes itself (several may match a prefix).
  85. *
  86. * `opts.after` / `opts.before` name another registered attribute and the
  87. * handler is inserted directly after / before it. A constraint naming an
  88. * unregistered attribute warns and keeps plain registration order, so
  89. * loading a module early cannot break the page. Registering the same name
  90. * twice warns and ignores the second handler.
  91. *
  92. * @param {string} name
  93. * @param {Function} fn `fn(el, scope)`
  94. * @param {{after?: string, before?: string}} [opts]
  95. */
  96. function registerAttribute(name, fn, opts) {
  97. if (typeof name !== 'string' || !name || typeof fn !== 'function') {
  98. console.warn('[statum] registerAttribute: expected an attribute name and a handler function');
  99. return;
  100. }
  101. for (var i = 0; i < attributeHandlers.length; i++) {
  102. if (attributeHandlers[i].name === name) {
  103. console.warn('[statum] registerAttribute: "' + name + '" is already registered; ignoring');
  104. return;
  105. }
  106. }
  107. var entry = { name: name, fn: fn, prefix: name.charAt(name.length - 1) === '.' };
  108. var anchor = opts && (opts.after || opts.before);
  109. var at = anchor ? handlerIndex(anchor) : -1;
  110. if (anchor && at === -1) {
  111. console.warn('[statum] registerAttribute: "' + anchor +
  112. '" is not registered; keeping registration order for "' + name + '"');
  113. }
  114. if (at !== -1 && opts.after) at += 1; // insert directly after the anchor
  115. if (at === -1) attributeHandlers.push(entry);
  116. else attributeHandlers.splice(at, 0, entry);
  117. }
  118. /** Position of a registered handler by name, or -1. */
  119. function handlerIndex(name) {
  120. for (var i = 0; i < attributeHandlers.length; i++) {
  121. if (attributeHandlers[i].name === name) return i;
  122. }
  123. return -1;
  124. }
  125. // ---------------------------------------------------------------------------
  126. // Rendering: loops, conditionals, bindings
  127. // ---------------------------------------------------------------------------
  128. /** Templates captured from loop holders. @type {WeakMap<Element, Element>} */
  129. var templates = new WeakMap();
  130. /** Instance nodes managed by a loop holder. @type {WeakMap<Element, Element[]>} */
  131. var instancesByHolder = new WeakMap();
  132. /** Loop instance nodes; skipped by the render walk (owned by their holder). */
  133. var instanceNodes = new WeakSet();
  134. /** Elements whose `stm-action` has been wired, to avoid double-binding. */
  135. var wired = new WeakSet();
  136. /**
  137. * Find a `stm-for-{name}-in` attribute on an element.
  138. * @returns {{varName: string, expr: string}|null}
  139. */
  140. function getStmFor(el) {
  141. var attrs = el.attributes;
  142. for (var i = 0; i < attrs.length; i++) {
  143. var match = attrs[i].name.match(/^stm-for-(.+)-in$/);
  144. if (match) return { varName: match[1], expr: attrs[i].value };
  145. }
  146. return null;
  147. }
  148. /** Remove any `stm-for-*` and `stm-key` attributes from an element. */
  149. function stripLoopAttrs(el) {
  150. var toRemove = [];
  151. for (var i = 0; i < el.attributes.length; i++) {
  152. var name = el.attributes[i].name;
  153. if (/^stm-for-(.+)-in$/.test(name) || name === 'stm-key') toRemove.push(name);
  154. }
  155. toRemove.forEach(function (n) { el.removeAttribute(n); });
  156. }
  157. /**
  158. * Evaluate an `stm-if` / `stm-else-if` / `stm-else` chain beginning at `el`,
  159. * showing the matching branch and hiding the rest.
  160. *
  161. * Branches are toggled with the `hidden` attribute rather than detached, so
  162. * every branch stays in the DOM and is re-evaluated on each render. (Detaching
  163. * the `stm-if` element would remove it from the render walk, leaving an
  164. * already-attached `stm-else` branch skipped and never refreshed.)
  165. * @returns {Element|null} The active branch element (or `null`).
  166. */
  167. function handleIfChain(el, scope) {
  168. var branches = [{ el: el, expr: el.getAttribute('stm-if') }];
  169. var cur = el.nextElementSibling;
  170. while (cur) {
  171. if (cur.hasAttribute('stm-else-if')) {
  172. branches.push({ el: cur, expr: cur.getAttribute('stm-else-if') });
  173. cur = cur.nextElementSibling;
  174. } else if (cur.hasAttribute('stm-else')) {
  175. branches.push({ el: cur, expr: null });
  176. break;
  177. } else {
  178. break;
  179. }
  180. }
  181. var activeEl = null;
  182. for (var i = 0; i < branches.length; i++) {
  183. var b = branches[i];
  184. if (b.expr === null || !!evalExpr(b.expr, scope)) { activeEl = b.el; break; }
  185. }
  186. branches.forEach(function (b) {
  187. if (b.el === activeEl) b.el.removeAttribute('hidden');
  188. else b.el.setAttribute('hidden', '');
  189. });
  190. return activeEl;
  191. }
  192. /** Toggle `stm-class.{name}` classes from their predicate expressions. */
  193. function handleClass(el, scope) {
  194. var attrs = el.attributes;
  195. for (var i = 0; i < attrs.length; i++) {
  196. var attr = attrs[i];
  197. if (attr.name.indexOf('stm-class.') === 0) {
  198. var cls = attr.name.slice('stm-class.'.length);
  199. if (cls && !!evalExpr(attr.value, scope)) el.classList.add(cls);
  200. else el.classList.remove(cls);
  201. }
  202. }
  203. }
  204. /**
  205. * Bind `stm-attribute.{name}`: a boolean result toggles the attribute, any
  206. * other defined value sets it, and `undefined` removes it.
  207. *
  208. * For booleans, prefer the element's IDL property when one exists (e.g.
  209. * `checked`, `disabled`, `hidden`) — setting the content attribute is
  210. * unreliable for these once the control is "dirty".
  211. */
  212. function handleAttribute(el, scope) {
  213. var attrs = el.attributes;
  214. for (var i = 0; i < attrs.length; i++) {
  215. var attr = attrs[i];
  216. if (attr.name.indexOf('stm-attribute.') === 0) {
  217. var name = attr.name.slice('stm-attribute.'.length);
  218. if (!name) continue;
  219. var value = evalExpr(attr.value, scope);
  220. if (value === undefined) {
  221. el.removeAttribute(name);
  222. } else if (typeof value === 'boolean') {
  223. if (name in el && typeof el[name] === 'boolean') el[name] = value;
  224. else if (value) el.setAttribute(name, '');
  225. else el.removeAttribute(name);
  226. } else {
  227. el.setAttribute(name, value === null ? '' : String(value));
  228. }
  229. }
  230. }
  231. }
  232. /**
  233. * Apply `stm-disabled`: when the predicate is truthy, disable the element and
  234. * its descendant form controls; otherwise enable them.
  235. */
  236. function handleDisabled(el, scope) {
  237. if (!el.hasAttribute('stm-disabled')) return;
  238. setDisabledCascade(el, !!evalExpr(el.getAttribute('stm-disabled'), scope));
  239. }
  240. /** Set the `disabled` attribute on an element and its descendant controls. */
  241. function setDisabledCascade(el, on) {
  242. var nodes = [el].concat(Array.prototype.slice.call(
  243. el.querySelectorAll('button,input,select,textarea,fieldset')));
  244. nodes.forEach(function (n) {
  245. if (on) n.setAttribute('disabled', ''); else n.removeAttribute('disabled');
  246. });
  247. }
  248. // ---------------------------------------------------------------------------
  249. // stm-text / stm-html
  250. // ---------------------------------------------------------------------------
  251. // (stm-markdown and stm-h-demotion live in statum-markdown.js and register
  252. // through the same seam, ordered after `stm-html`.)
  253. /**
  254. * Bind `stm-text`. Per the "leave original DOM content" policy, an
  255. * unresolved value (`undefined`) leaves the element untouched; otherwise
  256. * the value is stringified (`null` becomes empty).
  257. */
  258. function handleText(el, scope) {
  259. if (el.hasAttribute('stm-text')) {
  260. var text = evalExpr(el.getAttribute('stm-text'), scope);
  261. if (text !== undefined) el.textContent = text === null ? '' : String(text);
  262. }
  263. }
  264. /**
  265. * Bind `stm-html`, with the same policy as `stm-text`.
  266. */
  267. function handleHtml(el, scope) {
  268. if (el.hasAttribute('stm-html')) {
  269. var html = evalExpr(el.getAttribute('stm-html'), scope);
  270. if (html !== undefined) el.innerHTML = html === null ? '' : String(html);
  271. }
  272. }
  273. /**
  274. * Expand a loop holder: reconcile its instances against the current array.
  275. * Keyed loops (`stm-key`) reuse DOM nodes for unchanged keys to preserve
  276. * focus/selection/input state; unkeyed loops rebuild wholesale.
  277. */
  278. function handleLoop(el, scope) {
  279. var info = getStmFor(el);
  280. if (!info) return;
  281. var template = templates.get(el);
  282. if (!template) {
  283. template = el.cloneNode(true);
  284. stripLoopAttrs(template);
  285. templates.set(el, template);
  286. }
  287. var raw = evalExpr(info.expr, scope);
  288. if (raw === undefined) {
  289. // No value for the source path yet: clear instances and restore the
  290. // server-rendered holder content (progressive enhancement).
  291. var prev = instancesByHolder.get(el) || [];
  292. prev.forEach(function (n) { n.remove(); });
  293. instancesByHolder.set(el, []);
  294. if (el.getAttribute('data-stm-active') === '1') {
  295. el.removeAttribute('data-stm-active');
  296. el.removeAttribute('hidden');
  297. }
  298. return;
  299. }
  300. var arr = Array.isArray(raw) ? raw : [];
  301. // Activate: hide the holder so only rendered instances are visible.
  302. if (el.getAttribute('data-stm-active') !== '1') {
  303. el.setAttribute('data-stm-active', '1');
  304. el.setAttribute('hidden', '');
  305. }
  306. var keyExpr = el.getAttribute('stm-key');
  307. var next = [];
  308. if (keyExpr) {
  309. var oldByKey = new Map();
  310. (instancesByHolder.get(el) || []).forEach(function (n) {
  311. if (n.__stmKey !== undefined) oldByKey.set(n.__stmKey, n);
  312. });
  313. var used = new Set();
  314. arr.forEach(function (item) {
  315. var child = childScope(scope, info.varName, item);
  316. var key = evalExpr(keyExpr, child);
  317. var node = (key !== undefined && !used.has(key)) ? oldByKey.get(key) : undefined;
  318. if (node) {
  319. used.add(key);
  320. processElement(node, child);
  321. } else {
  322. node = template.cloneNode(true);
  323. node.__stmKey = key;
  324. instanceNodes.add(node);
  325. processElement(node, child);
  326. }
  327. next.push(node);
  328. });
  329. oldByKey.forEach(function (n, key) { if (!used.has(key)) n.remove(); });
  330. } else {
  331. (instancesByHolder.get(el) || []).forEach(function (n) { n.remove(); });
  332. arr.forEach(function (item) {
  333. var child = childScope(scope, info.varName, item);
  334. var node = template.cloneNode(true);
  335. instanceNodes.add(node);
  336. processElement(node, child);
  337. next.push(node);
  338. });
  339. }
  340. // Place instances in order, immediately after the holder.
  341. var ref = el;
  342. next.forEach(function (node) {
  343. if (node !== ref.nextSibling) ref.parentNode.insertBefore(node, ref.nextSibling);
  344. ref = node;
  345. });
  346. instancesByHolder.set(el, next);
  347. }
  348. /**
  349. * Process one element's bindings (and recurse). Loop holders are handled by
  350. * {@link handleLoop}; `stm-else-if` / `stm-else` are owned by their `stm-if`.
  351. */
  352. function processElement(el, scope) {
  353. if (getStmFor(el)) { handleLoop(el, scope); return; }
  354. if (el.hasAttribute('stm-else-if') || el.hasAttribute('stm-else')) return;
  355. if (el.hasAttribute('stm-if')) {
  356. var active = handleIfChain(el, scope);
  357. if (active) processInner(active, scope);
  358. return;
  359. }
  360. processInner(el, scope);
  361. }
  362. /**
  363. * Construct the element's effective scope. Local variable bindings
  364. * (stm-local-*, stm-declare-* — statum-binding.js, which may be absent)
  365. * attach through the bridge's scope-construction seam; without that
  366. * module the incoming scope passes through unchanged.
  367. */
  368. function buildScopeFor(el, scope) {
  369. var builder = bridge.getScopeBuilder();
  370. return builder ? builder(el, scope) : scope;
  371. }
  372. /**
  373. * Run the registered bindings on one element. The resolved handler order
  374. * (registration + after/before constraints) replaces the previous hardcoded
  375. * call chain: a throwing handler aborts that render cycle via renderAll's
  376. * try/catch, as before.
  377. */
  378. function processInner(el, scope) {
  379. var s = buildScopeFor(el, scope); // local variables (binding module)
  380. el.__stmScope = s; // latest scope, used when an action fires
  381. for (var i = 0; i < attributeHandlers.length; i++) {
  382. var h = attributeHandlers[i];
  383. if (h.prefix ? hasAttrPrefix(el, h.name) : el.hasAttribute(h.name)) h.fn(el, s);
  384. }
  385. wireAction(el);
  386. render(el, s);
  387. }
  388. /** Whether `el` carries any attribute starting with `prefix`. */
  389. function hasAttrPrefix(el, prefix) {
  390. var attrs = el.attributes;
  391. for (var i = 0; i < attrs.length; i++) {
  392. if (attrs[i].name.indexOf(prefix) === 0) return true;
  393. }
  394. return false;
  395. }
  396. /**
  397. * Walk an element's children, processing each. Loop instance nodes are
  398. * skipped here (they are owned and processed by their holder).
  399. */
  400. function render(root, scope) {
  401. if (!root || !root.children) return;
  402. // Snapshot the children: processing mutates the DOM (detaching branches,
  403. // inserting loop instances) and a live HTMLCollection would shift indices.
  404. var children = Array.prototype.slice.call(root.children);
  405. for (var i = 0; i < children.length; i++) {
  406. var el = children[i];
  407. if (instanceNodes.has(el)) continue;
  408. processElement(el, scope);
  409. }
  410. }
  411. // ---------------------------------------------------------------------------
  412. // HTML API: actions
  413. // ---------------------------------------------------------------------------
  414. /**
  415. * Collect data for an action trigger: `stm-data-{key}` attributes plus, for a
  416. * form, its named inputs.
  417. * @param {HTMLElement} el
  418. * @returns {object}
  419. */
  420. function collectData(el) {
  421. var data = {};
  422. var attrs = el.attributes;
  423. for (var i = 0; i < attrs.length; i++) {
  424. var name = attrs[i].name;
  425. if (name.indexOf('stm-data-') === 0) data[name.slice('stm-data-'.length)] = attrs[i].value;
  426. }
  427. if (el.tagName === 'FORM') {
  428. var fd = new FormData(el);
  429. fd.forEach(function (value, key) {
  430. data[key] = value instanceof File ? value.name : String(value);
  431. });
  432. }
  433. return data;
  434. }
  435. /**
  436. * Toggle the busy state of an action element: disables the element and its
  437. * descendant form controls and applies `stm-busy-class` classes while busy.
  438. *
  439. * On un-busy the disabled state is not "restored" to a captured snapshot
  440. * (that would clobber a `stm-disabled` value that `renderAll` applied during
  441. * the response); instead the nodes are re-enabled and `stm-disabled` is
  442. * re-evaluated, which re-disables any that should remain disabled.
  443. */
  444. function setBusyState(el, busy) {
  445. var nodes = [el].concat(Array.prototype.slice.call(
  446. el.querySelectorAll('button,input,select,textarea,fieldset')));
  447. var busyClasses = (el.getAttribute('stm-busy-class') || '').split(/\s+/).filter(Boolean);
  448. if (busy) {
  449. nodes.forEach(function (n) { n.setAttribute('disabled', ''); });
  450. busyClasses.forEach(function (c) { el.classList.add(c); });
  451. } else {
  452. nodes.forEach(function (n) { n.removeAttribute('disabled'); });
  453. busyClasses.forEach(function (c) { el.classList.remove(c); });
  454. reapplyDisabled(el);
  455. }
  456. }
  457. /** Re-apply `stm-disabled` to an element and any descendants that carry it. */
  458. function reapplyDisabled(el) {
  459. var scope = el.__stmScope || stateScope();
  460. if (el.hasAttribute('stm-disabled')) handleDisabled(el, scope);
  461. var scoped = el.querySelectorAll('[stm-disabled]');
  462. Array.prototype.forEach.call(scoped, function (n) {
  463. handleDisabled(n, n.__stmScope || scope);
  464. });
  465. }
  466. /**
  467. * Wire an element's primary action once. The listener resolves the action
  468. * object against the element's latest render scope, so loop instances bind to
  469. * their own item.
  470. */
  471. function wireAction(el) {
  472. if (!el.hasAttribute('stm-action') || wired.has(el)) return;
  473. wired.add(el);
  474. var eventName = el.tagName === 'FORM' ? 'submit' : 'click';
  475. el.addEventListener(eventName, function (event) {
  476. event.preventDefault();
  477. // Stash the action promise so stm-clear-on-success /
  478. // stm-clear-on-error (below) can await it and reset the form only on
  479. // the matching outcome.
  480. el.__stmAction = runElementAction(el);
  481. });
  482. }
  483. /**
  484. * Resolve and perform the action attached to an element, managing busy and
  485. * error/success-state classes around the request.
  486. * @param {HTMLElement} el
  487. */
  488. async function runElementAction(el) {
  489. var scope = el.__stmScope || stateScope();
  490. var action = evalExpr(el.getAttribute('stm-action'), scope);
  491. if (!action || typeof action.uri !== 'string') {
  492. // Unresolvable action: nothing was requested, so neither outcome
  493. // applies (cancellation, not error — a reset would eat the user's
  494. // input for a no-op).
  495. el.__stmActionFailed = true;
  496. el.__stmActionCancelled = true;
  497. return;
  498. }
  499. // Outcome flags read by stm-clear-on-success / stm-clear-on-error (below).
  500. // The returned promise resolves even on failure (the catch below swallows
  501. // the rejection so it does not surface as unhandled), so success needs
  502. // its own signal. Pessimistic: `failed` stays true until the action
  503. // completes successfully, so a declined confirm or an unresolvable
  504. // action never reads as success. `cancelled` marks the paths where no
  505. // request was ever made (unresolvable action, declined stm-confirm):
  506. // decline is cancellation, not error, so stm-clear-on-error must not
  507. // fire there either.
  508. el.__stmActionFailed = true;
  509. el.__stmActionCancelled = false;
  510. // stm-confirm: gate the action behind the confirm handler (async-capable,
  511. // so custom modals are supported).
  512. if (el.hasAttribute('stm-confirm')) {
  513. var confirmed = await triggerConfirm(el.getAttribute('stm-confirm'), el);
  514. if (!confirmed) {
  515. el.__stmActionCancelled = true;
  516. return;
  517. }
  518. }
  519. applyErrorClasses(el, false); // clear error/success state on a new attempt
  520. var data = collectData(el);
  521. setBusyState(el, true);
  522. var failed = false;
  523. try {
  524. await performAction(action, data, el);
  525. applyErrorClasses(el, 'success'); // success classes mirror the error ones
  526. } catch (err) {
  527. failed = true;
  528. // Already handled: stm-on-error classes and the onErrorHandler run inside
  529. // performAction/reportFailure. Swallow so an HTML-triggered action does
  530. // not surface as an unhandled rejection.
  531. } finally {
  532. el.__stmActionFailed = failed;
  533. setBusyState(el, false);
  534. }
  535. }
  536. // ---------------------------------------------------------------------------
  537. // Error/success-state classes (installed into core's reportFailure path)
  538. // ---------------------------------------------------------------------------
  539. /**
  540. * Apply or remove the `stm-on-error` / `stm-on-success` classes on an action
  541. * element. The two are mirrored: a failure applies the error classes and
  542. * clears the success classes; a success applies the success classes and
  543. * clears the error classes; a new attempt (or the no-attribute case) clears
  544. * both.
  545. *
  546. * Installed into core as `bridge.applyErrorClasses` so core's
  547. * reportFailure stays self-contained without knowing about classes; the
  548. * walk's own new-attempt call site (runElementAction) clears both.
  549. * @param {HTMLElement} el
  550. * @param {boolean|'success'} on `true` applies the error classes (and
  551. * clears success); `'success'` applies the success classes (and clears
  552. * error); `false` clears both.
  553. */
  554. function applyErrorClasses(el, on) {
  555. if (!el || !el.hasAttribute) return;
  556. if (!el.hasAttribute('stm-on-error') && !el.hasAttribute('stm-on-success')) return;
  557. var errClasses = el.hasAttribute('stm-on-error')
  558. ? (el.getAttribute('stm-on-error') || '').split(/\s+/).filter(Boolean)
  559. : [];
  560. var okClasses = el.hasAttribute('stm-on-success')
  561. ? (el.getAttribute('stm-on-success') || '').split(/\s+/).filter(Boolean)
  562. : [];
  563. var mode = on === 'success' ? 'success' : (on ? 'error' : 'clear');
  564. errClasses.forEach(function (c) {
  565. if (mode === 'error') el.classList.add(c); else el.classList.remove(c);
  566. });
  567. okClasses.forEach(function (c) {
  568. if (mode === 'success') el.classList.add(c); else el.classList.remove(c);
  569. });
  570. }
  571. // ---------------------------------------------------------------------------
  572. // Guards (stm-guard links) — installed into core's afterRenderCycle hook
  573. // ---------------------------------------------------------------------------
  574. /** Set when a guard has triggered a redirect, so further work is skipped. */
  575. var guardRedirected = false;
  576. /**
  577. * Evaluate every `<link rel="stm-guard">` in the document head. A guard whose
  578. * `require` predicate is false redirects the page to its `href`.
  579. *
  580. * Guards marked `pre-entrypoint` are evaluated on every cycle (including the
  581. * initial, pre-entrypoint render, which uses only hydrated state); guards
  582. * without it are evaluated only once the entrypoint has loaded. On every later
  583. * evaluate cycle (set/clear) all guards are re-checked, so logging out, for
  584. * example, can bounce the user off a guarded page.
  585. */
  586. function evaluateGuards() {
  587. if (guardRedirected) return;
  588. var scope = stateScope();
  589. var guards = document.querySelectorAll('link[rel~="stm-guard"]');
  590. for (var i = 0; i < guards.length; i++) {
  591. var g = guards[i];
  592. var preEntrypoint = g.hasAttribute('pre-entrypoint');
  593. if (!preEntrypoint && !bridge.entrypointLoaded()) continue;
  594. var require = g.getAttribute('require');
  595. if (!require) continue;
  596. if (!evalExpr(require, scope)) {
  597. guardRedirected = true;
  598. var href = g.getAttribute('href') || '/';
  599. console.warn('[statum] guard failed (' + require + '); redirecting to ' + href);
  600. window.location.href = href;
  601. return;
  602. }
  603. }
  604. }
  605. // ---------------------------------------------------------------------------
  606. // Forms (merged from the former statum-forms.js)
  607. // ---------------------------------------------------------------------------
  608. // stm-prefill / dirty tracking / stm-clear-on-success / stm-clear-on-error,
  609. // all hosted here; the public API is `statum.document`.
  610. /**
  611. * Elements the user has edited since they were last reset. A dirty element
  612. * is skipped by stm-prefill: the user's edit outranks the state snapshot.
  613. *
  614. * Hosted on the bridge (not module-private) so task 186's reconciliation
  615. * work can reuse the same set instead of guessing dirtiness from DOM
  616. * comparison. Use `bridge.markDirty` / `bridge.isDirty`; do not poke the
  617. * WeakSet directly.
  618. * @type {WeakSet<Element>}
  619. */
  620. var formsDirty = new WeakSet();
  621. bridge.formsDirty = formsDirty;
  622. /** Mark an element dirty. @param {Element} el */
  623. function markDirty(el) { formsDirty.add(el); }
  624. /** Whether an element is dirty. @param {Element} el @returns {boolean} */
  625. function isDirty(el) { return formsDirty.has(el); }
  626. bridge.markDirty = markDirty;
  627. bridge.isDirty = isDirty;
  628. /**
  629. * Un-dirty a form and all its form controls (the form element itself is
  630. * rarely dirty — its inputs are — but clearing both is harmless).
  631. * @param {Element} form
  632. */
  633. function unmarkForm(form) {
  634. formsDirty.delete(form);
  635. var nodes = form.querySelectorAll('button,input,select,textarea,fieldset');
  636. for (var i = 0; i < nodes.length; i++) formsDirty.delete(nodes[i]);
  637. }
  638. // ---------------------------------------------------------------------------
  639. // Document-level listeners (capture phase)
  640. // ---------------------------------------------------------------------------
  641. /** Mark the element an input/change event targeted. @param {Event} ev */
  642. function onEdit(ev) {
  643. var t = ev.target;
  644. if (t && t.nodeType === 1) markDirty(t);
  645. }
  646. /**
  647. * The reset event fires BEFORE the browser restores default values (it is
  648. * cancelable), so un-dirtying during reset is correct: the values become
  649. * the defaults — clean, as far as statum is concerned — and the next
  650. * prefill cycle can re-apply state.
  651. *
  652. * Double duty: the server's `form-reset` directive calls form.reset(),
  653. * which fires this same reset event, so the directive un-dirties forms
  654. * through this one listener too.
  655. * @param {Event} ev
  656. */
  657. function onReset(ev) {
  658. var f = ev.target;
  659. if (f && f.nodeType === 1 && f.tagName === 'FORM') unmarkForm(f);
  660. }
  661. document.addEventListener('input', onEdit, true);
  662. document.addEventListener('change', onEdit, true);
  663. document.addEventListener('reset', onReset, true);
  664. // ---------------------------------------------------------------------------
  665. // stm-prefill
  666. // ---------------------------------------------------------------------------
  667. /** Input types whose value is plain text (property `value`). */
  668. var TEXTLIKE_TYPES = [
  669. 'text', 'email', 'number', 'password', 'search', 'tel', 'url',
  670. 'date', 'datetime-local', 'month', 'week', 'time', 'range', 'color', 'hidden'
  671. ];
  672. /**
  673. * Bind `stm-prefill`: write the resolved expression into the control's
  674. * properties (never attributes).
  675. *
  676. * Semantics:
  677. * - Dirty elements are skipped entirely — the user's edit outranks state.
  678. * - `<input type=file>` is skipped entirely: assigning `el.value` from a
  679. * script is forbidden by the platform (and silently no-ops), so there is
  680. * nothing sensible to write. No per-element warning; the file-wide
  681. * "unresolved leaves the DOM alone" policy applies.
  682. * - `undefined` (unresolved expression) leaves the element untouched.
  683. * - `null` is written as `''` for value writes and `false` for checked
  684. * writes.
  685. * - Runs every render cycle like every handler; property writes are
  686. * naturally idempotent per cycle.
  687. *
  688. * @param {Element} el
  689. * @param {object} scope
  690. */
  691. function handlePrefill(el, scope) {
  692. if (isDirty(el)) return;
  693. var type = el.type || (el.tagName === 'INPUT' ? 'text' : '');
  694. if (el.tagName === 'INPUT' && type === 'file') return;
  695. var value = evalExpr(el.getAttribute('stm-prefill'), scope);
  696. if (value === undefined) return;
  697. if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && TEXTLIKE_TYPES.indexOf(type) !== -1)) {
  698. el.value = value == null ? '' : String(value);
  699. return;
  700. }
  701. if (el.tagName === 'INPUT' && (type === 'checkbox')) {
  702. el.checked = !!value;
  703. return;
  704. }
  705. if (el.tagName === 'INPUT' && type === 'radio') {
  706. // Per-element: see the module header. A group is prefilled by giving
  707. // its members the same stm-prefill expression.
  708. el.checked = (el.value === String(value));
  709. return;
  710. }
  711. if (el.tagName === 'SELECT') {
  712. if (type === 'multiple' || el.multiple === true) {
  713. var arr = Array.isArray(value) ? value : [value];
  714. var options = el.querySelectorAll('option');
  715. for (var i = 0; i < options.length; i++) {
  716. var opt = options[i];
  717. opt.selected = arr.indexOf(String(opt.value)) !== -1;
  718. }
  719. } else {
  720. el.value = value == null ? '' : String(value);
  721. }
  722. return;
  723. }
  724. // Other elements (buttons, fieldsets, ...): the attribute is unsupported;
  725. // leave the element untouched rather than inventing a semantic.
  726. }
  727. // ---------------------------------------------------------------------------
  728. // stm-clear-on-success / stm-clear-on-error
  729. // ---------------------------------------------------------------------------
  730. /** Forms already warned about a missing stm-action. @type {WeakSet} */
  731. var warnSeen = new WeakSet();
  732. /** Warn once about a clear-on-* attribute on a form without stm-action. */
  733. function warnNoAction(f, attr) {
  734. if (warnSeen.has(f)) return;
  735. warnSeen.add(f);
  736. console.warn('[statum] ' + attr + ' on a form without stm-action does nothing; ' +
  737. 'put stm-action on the form itself');
  738. }
  739. /**
  740. * Reset a form when its own stm-action settles on the matching outcome.
  741. * Registered on the document (capture phase) so it sees every submit; the
  742. * promise is stashed by wireAction's target-phase listener on the form
  743. * itself (el.__stmAction — see above).
  744. *
  745. * The stash is read in a MICROTASK: this capture-phase listener runs before
  746. * the form's target-phase listener has stashed the CURRENT action promise,
  747. * so a synchronous read would see the previous submit's promise (or none).
  748. * Event dispatch completes synchronously, so by the time the microtask runs
  749. * the stash always holds this submit's promise.
  750. *
  751. * `stm-clear-on-success` and `stm-clear-on-error` are supported on a FORM
  752. * whose own stm-action performs the request (wireAction wires a FORM for
  753. * `submit`). If the form carries the attribute but no stm-action of its
  754. * own, there is no promise to await, so the attribute is inert there; warn
  755. * once per form.
  756. *
  757. * A DECLINED stm-confirm never clears: decline is cancellation, not error.
  758. * runElementAction resolves (never rejects) for declines, and marks the
  759. * outcome `cancelled` (`__stmActionCancelled === true`), which this
  760. * success-path check excludes. The reject handler below is only reached on
  761. * a real request failure, where `__stmActionFailed === true` by
  762. * construction.
  763. *
  764. * @param {Event} ev
  765. */
  766. function onSubmit(ev) {
  767. var f = ev.target;
  768. if (!f || f.nodeType !== 1 || f.tagName !== 'FORM') return;
  769. var onSuccess = f.hasAttribute && f.hasAttribute('stm-clear-on-success');
  770. var onError = f.hasAttribute && f.hasAttribute('stm-clear-on-error');
  771. if (!onSuccess && !onError) return;
  772. Promise.resolve().then(function () {
  773. var promise = f.__stmAction;
  774. if (!promise) {
  775. if (onSuccess) warnNoAction(f, 'stm-clear-on-success');
  776. if (onError) warnNoAction(f, 'stm-clear-on-error');
  777. return;
  778. }
  779. // runElementAction RESOLVES even when the action failed (it swallows
  780. // the rejection so it does not surface as unhandled), so both outcomes
  781. // are read from its flags on the resolve path: success clears with
  782. // __stmActionFailed === false, failure with === true (and not
  783. // cancelled — a declined confirm is neither). The reject handler is
  784. // defensive only; by construction the promise never rejects.
  785. promise.then(function () {
  786. if (f.__stmActionCancelled === true) return; // decline: no request, no clear
  787. if (onSuccess && f.__stmActionFailed === false) f.reset();
  788. else if (onError && f.__stmActionFailed === true) f.reset();
  789. }, function () {
  790. if (onError && f.__stmActionFailed === true && f.__stmActionCancelled !== true) {
  791. f.reset();
  792. }
  793. });
  794. });
  795. }
  796. document.addEventListener('submit', onSubmit, true);
  797. // ---------------------------------------------------------------------------
  798. // Programmatic API
  799. // ---------------------------------------------------------------------------
  800. /**
  801. * Resolve the form an element belongs to: the element itself if it is a
  802. * form, `el.form` where available (form controls), else the closest form
  803. * ancestor.
  804. * @param {Element} el
  805. * @returns {Element|null}
  806. */
  807. function resolveForm(el) {
  808. if (!el) return null;
  809. if (el.tagName === 'FORM') return el;
  810. if (el.form) return el.form;
  811. if (typeof el.closest === 'function') return el.closest('form');
  812. return null;
  813. }
  814. /** Reset exactly one form (the reset event un-dirties it). @param {Element} formOrEl */
  815. function resetForm(formOrEl) {
  816. var f = resolveForm(formOrEl);
  817. if (f) f.reset();
  818. }
  819. window.statum.document = {
  820. /** Reset one form (or the form owning an element). */
  821. reset: resetForm,
  822. /** Mark an element dirty (skipped by stm-prefill until reset). */
  823. markDirty: markDirty,
  824. /** Whether an element is dirty. */
  825. isDirty: isDirty
  826. };
  827. // ---------------------------------------------------------------------------
  828. // Boot (moved from core): body-attribute config, boot visibility, styles
  829. // ---------------------------------------------------------------------------
  830. /**
  831. * Inject the stylesheet that makes the `hidden` attribute authoritative.
  832. * Statum toggles `stm-if`/`stm-else` branches and loop holders via
  833. * `hidden`, whose user-agent rule loses specificity battles to ordinary
  834. * author CSS (a rule like `li { display: flex }` let hidden loop holders
  835. * leak as phantom rows). Idempotent: skipped when the stylesheet is
  836. * already present.
  837. */
  838. function injectHiddenStyle() {
  839. var id = 'statum-hidden-style';
  840. if (document.getElementById(id)) return;
  841. var head = document.head;
  842. if (!head) return;
  843. var style = document.createElement('style');
  844. style.id = id;
  845. style.textContent = '[hidden]{display:none !important}';
  846. head.appendChild(style);
  847. }
  848. /**
  849. * Scan the document for `pstm-*` attributes and `<pstm-*>` elements, which
  850. * are intended for server-side pre-rendering and should never reach the
  851. * browser. Emits a single `console.warn` summarizing them.
  852. */
  853. function warnServerSideMarkup() {
  854. var found = [];
  855. var els = document.querySelectorAll('*');
  856. for (var i = 0; i < els.length; i++) {
  857. var el = els[i];
  858. var tag = el.tagName.toLowerCase();
  859. if (tag.indexOf('pstm-') === 0) found.push('<' + tag + '>');
  860. var attrs = el.attributes;
  861. for (var j = 0; j < attrs.length; j++) {
  862. var name = attrs[j].name;
  863. if (name.indexOf('pstm-') === 0) found.push('<' + tag + '> ' + name);
  864. }
  865. }
  866. if (found.length) {
  867. console.warn('[statum] ' + found.length + ' server-side pstm-* attribute/element(s) ' +
  868. 'found in the DOM; they should have been resolved during pre-rendering: ' +
  869. found.slice(0, 10).join(', ') + (found.length > 10 ? ' …' : ''));
  870. }
  871. }
  872. /**
  873. * Read the `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker`
  874. * `<body>` attributes into core's config object (`statum.config`). Core's
  875. * init() then uses the config as given — it never reads the DOM itself, so
  876. * headless pages point `statum.config` at their own endpoints instead.
  877. */
  878. function readBootConfig() {
  879. var body = document.body;
  880. if (!body) return;
  881. if (body.hasAttribute('stm-entrypoint')) window.statum.config.entrypointUrl = body.getAttribute('stm-entrypoint');
  882. if (body.hasAttribute('stm-slots')) window.statum.config.slotsUrl = body.getAttribute('stm-slots');
  883. if (body.hasAttribute('stm-channel')) window.statum.config.channelUrl = body.getAttribute('stm-channel');
  884. if (body.hasAttribute('stm-worker')) window.statum.config.workerUrl = body.getAttribute('stm-worker');
  885. }
  886. /**
  887. * Toggle pre/post-entrypoint visibility with the `hidden` attribute. While
  888. * loading, `stm-preloader` elements are shown and `stm-content` elements
  889. * hidden; once ready, the reverse.
  890. *
  891. * To avoid a flash of un-bound template before the script runs, authors may
  892. * pre-set `hidden` directly on `stm-content` in the HTML (e.g.
  893. * `<div stm-content hidden>`); the library removes it once ready. The library
  894. * also sets `hidden` itself during loading as a fallback for authors who do
  895. * not pre-set it.
  896. * @param {boolean} ready
  897. */
  898. function setBootState(ready) {
  899. var preloaders = document.querySelectorAll('[stm-preloader]');
  900. var contents = document.querySelectorAll('[stm-content]');
  901. for (var i = 0; i < preloaders.length; i++) {
  902. ready ? preloaders[i].setAttribute('hidden', '') : preloaders[i].removeAttribute('hidden');
  903. }
  904. for (var j = 0; j < contents.length; j++) {
  905. ready ? contents[j].removeAttribute('hidden') : contents[j].setAttribute('hidden', '');
  906. }
  907. }
  908. // ---------------------------------------------------------------------------
  909. // Installation
  910. // ---------------------------------------------------------------------------
  911. // Registration order mirrors the order these handlers had when processInner
  912. // called them directly; stm-prefill (from the former forms module) is first
  913. // so its property writes feed the controls before any class/attribute
  914. // predicate evaluates; statum-markdown.js constrains its own handlers
  915. // relative to `stm-html`.
  916. registerAttribute('stm-prefill', handlePrefill);
  917. registerAttribute('stm-class.', handleClass);
  918. registerAttribute('stm-attribute.', handleAttribute);
  919. registerAttribute('stm-disabled', handleDisabled);
  920. registerAttribute('stm-text', handleText);
  921. registerAttribute('stm-html', handleHtml);
  922. // The registry itself becomes the seam other modules register through.
  923. // First drain anything a module registered against core alone (markdown,
  924. // for instance, may legitimately load without this module): its
  925. // after-constraints name document handlers that now exist, so they resolve
  926. // properly instead of warn-and-keep-position.
  927. bridge.takePendingRegistrations().forEach(function (reg) {
  928. registerAttribute(reg[0], reg[1], reg[2]);
  929. });
  930. bridge.attributeHandlers = attributeHandlers;
  931. bridge.registerAttribute = registerAttribute;
  932. window.statum.registerAttribute = registerAttribute;
  933. // Install the presentation hooks core calls. Core's renderAll drives the
  934. // walk defined here; reportFailure reaches the classes defined here; init
  935. // reaches the boot visibility defined here; every render cycle ends with
  936. // the guard evaluation defined here. The driver itself honours the guard
  937. // state: once a guard has redirected, walks are skipped (core's renderAll
  938. // no longer holds that flag — the mandate's "driver checks its own guard
  939. // state before walking" mechanism).
  940. bridge.renderDriver(function (scope) {
  941. if (guardRedirected) return;
  942. var passStart = bridge.getPassStartHook();
  943. if (passStart) passStart(scope); // per-pass resets + global sweeps
  944. render(document.body, scope);
  945. });
  946. bridge.setBootState(setBootState);
  947. bridge.afterRenderCycle(evaluateGuards);
  948. bridge.applyErrorClasses(applyErrorClasses);
  949. // The DOM-shaped terminal directives (moved verbatim from core when it
  950. // went headless): core still dispatches and orders them, document owns
  951. // the element lookup / form construction / navigation.
  952. bridge.setDomTerminal(function (kind, d) {
  953. if (kind === 'form-submit') doFormSubmit(d);
  954. else if (kind === 'form-reset') doFormReset(d);
  955. else if (kind === 'navigate') doNavigate(d);
  956. else if (kind === 'post') doPost(d);
  957. });
  958. /** Execute a `form-submit` directive: optionally override action/method, then submit. */
  959. function doFormSubmit(d) {
  960. var form = document.getElementById(d.form);
  961. if (form) {
  962. if (d.action) form.setAttribute('action', d.action);
  963. if (d.method) form.setAttribute('method', d.method);
  964. form.submit();
  965. }
  966. }
  967. /** Execute a `form-reset` directive. */
  968. function doFormReset(d) {
  969. var form = document.getElementById(d.form);
  970. if (form) form.reset();
  971. }
  972. /** Execute a `navigate` directive as a full page load. */
  973. function doNavigate(d) {
  974. if (d.uri) window.location.href = d.uri;
  975. }
  976. /** Execute a `post` directive as a real (navigating) form POST. */
  977. function doPost(d) {
  978. if (!d.uri) return;
  979. var form = document.createElement('form');
  980. form.method = 'POST';
  981. form.action = d.uri;
  982. form.style.display = 'none';
  983. var data = d.data || {};
  984. Object.keys(data).forEach(function (name) {
  985. var input = document.createElement('input');
  986. input.type = 'hidden';
  987. input.name = name;
  988. input.value = data[name] == null ? '' : String(data[name]);
  989. form.appendChild(input);
  990. });
  991. document.body.appendChild(form);
  992. form.submit();
  993. }
  994. // Boot configuration must be in place before core's init() runs: the same
  995. // script tags that load this module also trigger core's autoInit on
  996. // DOMContentLoaded, and a synchronous page load has this module loaded by
  997. // then. (Direct statum.init() calls by embedding frameworks happen after
  998. // the module set loads, so the ordering holds there too.)
  999. readBootConfig();
  1000. // Before the first render, so `hidden` wins over author CSS from the very
  1001. // first paint.
  1002. try { injectHiddenStyle(); } catch (e) {}
  1003. try { warnServerSideMarkup(); } catch (e) {}
  1004. })();