statum-document.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. /*!
  2. * Statum - client-side state library (document module).
  3. *
  4. * The render walk: expands `stm-for` loops and `stm-if` chains, applies the
  5. * structural and DOM-level `stm-*` bindings (`stm-class.*`,
  6. * `stm-attribute.*`, `stm-disabled`, `stm-text`, `stm-html`), wires
  7. * `stm-action` elements, and recurses into child elements. The bindings
  8. * register through core's attribute seam and this module installs core's
  9. * render driver, so `renderAll` drives the walk defined here. The
  10. * structural attributes (`stm-for`, `stm-if`/`stm-else-if`/`stm-else` and
  11. * `stm-def-*`) stay hardcoded in the walk: they build the scope and the
  12. * element list every handler receives.
  13. *
  14. * Load order: SECOND - requires statum-core.js, which creates `window.statum`
  15. * and the `_bridge` this module registers through. Like core: an IIFE, no
  16. * dependencies, no build step, plain script tag.
  17. */
  18. (function () {
  19. 'use strict';
  20. if (!window.statum || !window.statum._bridge) {
  21. console.error('[statum] statum-document.js requires statum-core.js');
  22. return;
  23. }
  24. // Core machinery shared through the module seam (see statum-core.js).
  25. // Aliased so the moved code keeps its original call shapes.
  26. var bridge = window.statum._bridge;
  27. var evalExpr = bridge.evalExpr;
  28. var childScope = bridge.childScope;
  29. var stateScope = bridge.stateScope;
  30. var triggerConfirm = bridge.triggerConfirm;
  31. var performAction = bridge.performAction;
  32. var applyErrorClasses = bridge.applyErrorClasses;
  33. // ---------------------------------------------------------------------------
  34. // Rendering: loops, conditionals, bindings
  35. // ---------------------------------------------------------------------------
  36. /** Templates captured from loop holders. @type {WeakMap<Element, Element>} */
  37. var templates = new WeakMap();
  38. /** Instance nodes managed by a loop holder. @type {WeakMap<Element, Element[]>} */
  39. var instancesByHolder = new WeakMap();
  40. /** Loop instance nodes; skipped by the render walk (owned by their holder). */
  41. var instanceNodes = new WeakSet();
  42. /** Elements whose `stm-action` has been wired, to avoid double-binding. */
  43. var wired = new WeakSet();
  44. /**
  45. * Find a `stm-for-{name}-in` attribute on an element.
  46. * @returns {{varName: string, expr: string}|null}
  47. */
  48. function getStmFor(el) {
  49. var attrs = el.attributes;
  50. for (var i = 0; i < attrs.length; i++) {
  51. var match = attrs[i].name.match(/^stm-for-(.+)-in$/);
  52. if (match) return { varName: match[1], expr: attrs[i].value };
  53. }
  54. return null;
  55. }
  56. /** Remove any `stm-for-*` and `stm-key` attributes from an element. */
  57. function stripLoopAttrs(el) {
  58. var toRemove = [];
  59. for (var i = 0; i < el.attributes.length; i++) {
  60. var name = el.attributes[i].name;
  61. if (/^stm-for-(.+)-in$/.test(name) || name === 'stm-key') toRemove.push(name);
  62. }
  63. toRemove.forEach(function (n) { el.removeAttribute(n); });
  64. }
  65. /**
  66. * Evaluate an `stm-if` / `stm-else-if` / `stm-else` chain beginning at `el`,
  67. * showing the matching branch and hiding the rest.
  68. *
  69. * Branches are toggled with the `hidden` attribute rather than detached, so
  70. * every branch stays in the DOM and is re-evaluated on each render. (Detaching
  71. * the `stm-if` element would remove it from the render walk, leaving an
  72. * already-attached `stm-else` branch skipped and never refreshed.)
  73. * @returns {Element|null} The active branch element (or `null`).
  74. */
  75. function handleIfChain(el, scope) {
  76. var branches = [{ el: el, expr: el.getAttribute('stm-if') }];
  77. var cur = el.nextElementSibling;
  78. while (cur) {
  79. if (cur.hasAttribute('stm-else-if')) {
  80. branches.push({ el: cur, expr: cur.getAttribute('stm-else-if') });
  81. cur = cur.nextElementSibling;
  82. } else if (cur.hasAttribute('stm-else')) {
  83. branches.push({ el: cur, expr: null });
  84. break;
  85. } else {
  86. break;
  87. }
  88. }
  89. var activeEl = null;
  90. for (var i = 0; i < branches.length; i++) {
  91. var b = branches[i];
  92. if (b.expr === null || !!evalExpr(b.expr, scope)) { activeEl = b.el; break; }
  93. }
  94. branches.forEach(function (b) {
  95. if (b.el === activeEl) b.el.removeAttribute('hidden');
  96. else b.el.setAttribute('hidden', '');
  97. });
  98. return activeEl;
  99. }
  100. /** Toggle `stm-class.{name}` classes from their predicate expressions. */
  101. function handleClass(el, scope) {
  102. var attrs = el.attributes;
  103. for (var i = 0; i < attrs.length; i++) {
  104. var attr = attrs[i];
  105. if (attr.name.indexOf('stm-class.') === 0) {
  106. var cls = attr.name.slice('stm-class.'.length);
  107. if (cls && !!evalExpr(attr.value, scope)) el.classList.add(cls);
  108. else el.classList.remove(cls);
  109. }
  110. }
  111. }
  112. /**
  113. * Bind `stm-attribute.{name}`: a boolean result toggles the attribute, any
  114. * other defined value sets it, and `undefined` removes it.
  115. *
  116. * For booleans, prefer the element's IDL property when one exists (e.g.
  117. * `checked`, `disabled`, `hidden`) — setting the content attribute is
  118. * unreliable for these once the control is "dirty".
  119. */
  120. function handleAttribute(el, scope) {
  121. var attrs = el.attributes;
  122. for (var i = 0; i < attrs.length; i++) {
  123. var attr = attrs[i];
  124. if (attr.name.indexOf('stm-attribute.') === 0) {
  125. var name = attr.name.slice('stm-attribute.'.length);
  126. if (!name) continue;
  127. var value = evalExpr(attr.value, scope);
  128. if (value === undefined) {
  129. el.removeAttribute(name);
  130. } else if (typeof value === 'boolean') {
  131. if (name in el && typeof el[name] === 'boolean') el[name] = value;
  132. else if (value) el.setAttribute(name, '');
  133. else el.removeAttribute(name);
  134. } else {
  135. el.setAttribute(name, value === null ? '' : String(value));
  136. }
  137. }
  138. }
  139. }
  140. /**
  141. * Apply `stm-disabled`: when the predicate is truthy, disable the element and
  142. * its descendant form controls; otherwise enable them.
  143. */
  144. function handleDisabled(el, scope) {
  145. if (!el.hasAttribute('stm-disabled')) return;
  146. setDisabledCascade(el, !!evalExpr(el.getAttribute('stm-disabled'), scope));
  147. }
  148. /** Set the `disabled` attribute on an element and its descendant controls. */
  149. function setDisabledCascade(el, on) {
  150. var nodes = [el].concat(Array.prototype.slice.call(
  151. el.querySelectorAll('button,input,select,textarea,fieldset')));
  152. nodes.forEach(function (n) {
  153. if (on) n.setAttribute('disabled', ''); else n.removeAttribute('disabled');
  154. });
  155. }
  156. // ---------------------------------------------------------------------------
  157. // stm-text / stm-html
  158. // ---------------------------------------------------------------------------
  159. // (stm-markdown and stm-h-demotion moved to statum-markdown.js and register
  160. // through the same seam, ordered after `stm-html`.)
  161. /**
  162. * Bind `stm-text`. Per the "leave original DOM content" policy, an
  163. * unresolved value (`undefined`) leaves the element untouched; otherwise
  164. * the value is stringified (`null` becomes empty).
  165. */
  166. function handleText(el, scope) {
  167. if (el.hasAttribute('stm-text')) {
  168. var text = evalExpr(el.getAttribute('stm-text'), scope);
  169. if (text !== undefined) el.textContent = text === null ? '' : String(text);
  170. }
  171. }
  172. /**
  173. * Bind `stm-html`, with the same policy as `stm-text`.
  174. */
  175. function handleHtml(el, scope) {
  176. if (el.hasAttribute('stm-html')) {
  177. var html = evalExpr(el.getAttribute('stm-html'), scope);
  178. if (html !== undefined) el.innerHTML = html === null ? '' : String(html);
  179. }
  180. }
  181. /**
  182. * Expand a loop holder: reconcile its instances against the current array.
  183. * Keyed loops (`stm-key`) reuse DOM nodes for unchanged keys to preserve
  184. * focus/selection/input state; unkeyed loops rebuild wholesale.
  185. */
  186. function handleLoop(el, scope) {
  187. var info = getStmFor(el);
  188. if (!info) return;
  189. var template = templates.get(el);
  190. if (!template) {
  191. template = el.cloneNode(true);
  192. stripLoopAttrs(template);
  193. templates.set(el, template);
  194. }
  195. var raw = evalExpr(info.expr, scope);
  196. if (raw === undefined) {
  197. // No value for the source path yet: clear instances and restore the
  198. // server-rendered holder content (progressive enhancement).
  199. var prev = instancesByHolder.get(el) || [];
  200. prev.forEach(function (n) { n.remove(); });
  201. instancesByHolder.set(el, []);
  202. if (el.getAttribute('data-stm-active') === '1') {
  203. el.removeAttribute('data-stm-active');
  204. el.removeAttribute('hidden');
  205. }
  206. return;
  207. }
  208. var arr = Array.isArray(raw) ? raw : [];
  209. // Activate: hide the holder so only rendered instances are visible.
  210. if (el.getAttribute('data-stm-active') !== '1') {
  211. el.setAttribute('data-stm-active', '1');
  212. el.setAttribute('hidden', '');
  213. }
  214. var keyExpr = el.getAttribute('stm-key');
  215. var next = [];
  216. if (keyExpr) {
  217. var oldByKey = new Map();
  218. (instancesByHolder.get(el) || []).forEach(function (n) {
  219. if (n.__stmKey !== undefined) oldByKey.set(n.__stmKey, n);
  220. });
  221. var used = new Set();
  222. arr.forEach(function (item) {
  223. var child = childScope(scope, info.varName, item);
  224. var key = evalExpr(keyExpr, child);
  225. var node = (key !== undefined && !used.has(key)) ? oldByKey.get(key) : undefined;
  226. if (node) {
  227. used.add(key);
  228. processElement(node, child);
  229. } else {
  230. node = template.cloneNode(true);
  231. node.__stmKey = key;
  232. instanceNodes.add(node);
  233. processElement(node, child);
  234. }
  235. next.push(node);
  236. });
  237. oldByKey.forEach(function (n, key) { if (!used.has(key)) n.remove(); });
  238. } else {
  239. (instancesByHolder.get(el) || []).forEach(function (n) { n.remove(); });
  240. arr.forEach(function (item) {
  241. var child = childScope(scope, info.varName, item);
  242. var node = template.cloneNode(true);
  243. instanceNodes.add(node);
  244. processElement(node, child);
  245. next.push(node);
  246. });
  247. }
  248. // Place instances in order, immediately after the holder.
  249. var ref = el;
  250. next.forEach(function (node) {
  251. if (node !== ref.nextSibling) ref.parentNode.insertBefore(node, ref.nextSibling);
  252. ref = node;
  253. });
  254. instancesByHolder.set(el, next);
  255. }
  256. /**
  257. * Process one element's bindings (and recurse). Loop holders are handled by
  258. * {@link handleLoop}; `stm-else-if` / `stm-else` are owned by their `stm-if`.
  259. */
  260. function processElement(el, scope) {
  261. if (getStmFor(el)) { handleLoop(el, scope); return; }
  262. if (el.hasAttribute('stm-else-if') || el.hasAttribute('stm-else')) return;
  263. if (el.hasAttribute('stm-if')) {
  264. var active = handleIfChain(el, scope);
  265. if (active) processInner(active, scope);
  266. return;
  267. }
  268. processInner(el, scope);
  269. }
  270. /**
  271. * Collect `stm-def-{name}-as` attributes on an element into a child scope.
  272. * Each expression is evaluated against the incoming scope (so the new
  273. * variables cannot reference each other within the same element), and the
  274. * results are added to a child scope visible to the element and its
  275. * descendants. Multiple defs on one element are supported. If there are no
  276. * defs, the original scope is returned unchanged.
  277. * @param {Element} el
  278. * @param {object} scope
  279. * @returns {object}
  280. */
  281. function applyDefs(el, scope) {
  282. var attrs = el.attributes;
  283. var child = null;
  284. for (var i = 0; i < attrs.length; i++) {
  285. var name = attrs[i].name;
  286. if (name.indexOf('stm-def-') === 0 && name.slice(-3) === '-as') {
  287. var varName = name.slice(8, -3);
  288. if (!varName) continue;
  289. if (!child) child = Object.create(scope || null);
  290. child[varName] = evalExpr(attrs[i].value, scope);
  291. }
  292. }
  293. return child || scope;
  294. }
  295. /**
  296. * Bind the non-control-flow attributes and recurse into children.
  297. *
  298. * Handlers run in the seam's resolved order (see registerAttribute in
  299. * statum-core.js): once all modules have loaded it is stm-class.* ->
  300. * stm-attribute.* -> stm-disabled -> stm-text -> stm-html ->
  301. * stm-markdown -> stm-h-demotion, the order this walk used when it called
  302. * the handlers directly. A throwing handler aborts the render cycle via
  303. * renderAll's try/catch, as before.
  304. */
  305. function processInner(el, scope) {
  306. var s = applyDefs(el, scope); // local definitions: element + descendants
  307. el.__stmScope = s; // latest scope, used when an action fires
  308. var registered = bridge.attributeHandlers;
  309. for (var i = 0; i < registered.length; i++) {
  310. var h = registered[i];
  311. if (h.prefix ? hasAttrPrefix(el, h.name) : el.hasAttribute(h.name)) h.fn(el, s);
  312. }
  313. wireAction(el);
  314. render(el, s);
  315. }
  316. /** Whether `el` carries any attribute starting with `prefix`. */
  317. function hasAttrPrefix(el, prefix) {
  318. var attrs = el.attributes;
  319. for (var i = 0; i < attrs.length; i++) {
  320. if (attrs[i].name.indexOf(prefix) === 0) return true;
  321. }
  322. return false;
  323. }
  324. /**
  325. * Walk an element's children, processing each. Loop instance nodes are
  326. * skipped here (they are owned and processed by their holder).
  327. */
  328. function render(root, scope) {
  329. if (!root || !root.children) return;
  330. // Snapshot the children: processing mutates the DOM (detaching branches,
  331. // inserting loop instances) and a live HTMLCollection would shift indices.
  332. var children = Array.prototype.slice.call(root.children);
  333. for (var i = 0; i < children.length; i++) {
  334. var el = children[i];
  335. if (instanceNodes.has(el)) continue;
  336. processElement(el, scope);
  337. }
  338. }
  339. // ---------------------------------------------------------------------------
  340. // HTML API: actions
  341. // ---------------------------------------------------------------------------
  342. /**
  343. * Collect data for an action trigger: `stm-data-{key}` attributes plus, for a
  344. * form, its named inputs.
  345. * @param {HTMLElement} el
  346. * @returns {object}
  347. */
  348. function collectData(el) {
  349. var data = {};
  350. var attrs = el.attributes;
  351. for (var i = 0; i < attrs.length; i++) {
  352. var name = attrs[i].name;
  353. if (name.indexOf('stm-data-') === 0) data[name.slice('stm-data-'.length)] = attrs[i].value;
  354. }
  355. if (el.tagName === 'FORM') {
  356. var fd = new FormData(el);
  357. fd.forEach(function (value, key) {
  358. data[key] = value instanceof File ? value.name : String(value);
  359. });
  360. }
  361. return data;
  362. }
  363. /**
  364. * Toggle the busy state of an action element: disables the element and its
  365. * descendant form controls and applies `stm-busy-class` classes while busy.
  366. *
  367. * On un-busy the disabled state is not "restored" to a captured snapshot
  368. * (that would clobber a `stm-disabled` value that `renderAll` applied during
  369. * the response); instead the nodes are re-enabled and `stm-disabled` is
  370. * re-evaluated, which re-disables any that should remain disabled.
  371. */
  372. function setBusyState(el, busy) {
  373. var nodes = [el].concat(Array.prototype.slice.call(
  374. el.querySelectorAll('button,input,select,textarea,fieldset')));
  375. var busyClasses = (el.getAttribute('stm-busy-class') || '').split(/\s+/).filter(Boolean);
  376. if (busy) {
  377. nodes.forEach(function (n) { n.setAttribute('disabled', ''); });
  378. busyClasses.forEach(function (c) { el.classList.add(c); });
  379. } else {
  380. nodes.forEach(function (n) { n.removeAttribute('disabled'); });
  381. busyClasses.forEach(function (c) { el.classList.remove(c); });
  382. reapplyDisabled(el);
  383. }
  384. }
  385. /** Re-apply `stm-disabled` to an element and any descendants that carry it. */
  386. function reapplyDisabled(el) {
  387. var scope = el.__stmScope || stateScope();
  388. if (el.hasAttribute('stm-disabled')) handleDisabled(el, scope);
  389. var scoped = el.querySelectorAll('[stm-disabled]');
  390. Array.prototype.forEach.call(scoped, function (n) {
  391. handleDisabled(n, n.__stmScope || scope);
  392. });
  393. }
  394. /**
  395. * Wire an element's primary action once. The listener resolves the action
  396. * object against the element's latest render scope, so loop instances bind to
  397. * their own item.
  398. */
  399. function wireAction(el) {
  400. if (!el.hasAttribute('stm-action') || wired.has(el)) return;
  401. wired.add(el);
  402. var eventName = el.tagName === 'FORM' ? 'submit' : 'click';
  403. el.addEventListener(eventName, function (event) {
  404. event.preventDefault();
  405. // Stash the action promise so stm-clear-on-success (statum-forms.js)
  406. // can await it and reset the form only when the action resolved.
  407. el.__stmAction = runElementAction(el);
  408. });
  409. }
  410. /**
  411. * Resolve and perform the action attached to an element, managing busy and
  412. * error-state classes around the request.
  413. * @param {HTMLElement} el
  414. */
  415. async function runElementAction(el) {
  416. var scope = el.__stmScope || stateScope();
  417. var action = evalExpr(el.getAttribute('stm-action'), scope);
  418. if (!action || typeof action.uri !== 'string') return;
  419. // Outcome flag read by stm-clear-on-success (statum-forms.js). The
  420. // returned promise resolves even on failure (the catch below swallows
  421. // the rejection so it does not surface as unhandled), so success needs
  422. // its own signal. Pessimistic: true until the action completes
  423. // successfully, so a declined confirm or an unresolvable action never
  424. // reads as success.
  425. el.__stmActionFailed = true;
  426. // stm-confirm: gate the action behind the confirm handler (async-capable,
  427. // so custom modals are supported).
  428. if (el.hasAttribute('stm-confirm')) {
  429. var confirmed = await triggerConfirm(el.getAttribute('stm-confirm'), el);
  430. if (!confirmed) return;
  431. }
  432. applyErrorClasses(el, false); // clear error state on a new attempt
  433. var data = collectData(el);
  434. setBusyState(el, true);
  435. var failed = false;
  436. try {
  437. await performAction(action, data, el);
  438. } catch (err) {
  439. failed = true;
  440. // Already handled: stm-on-error classes and the onErrorHandler run inside
  441. // performAction/reportFailure. Swallow so an HTML-triggered action does
  442. // not surface as an unhandled rejection.
  443. } finally {
  444. el.__stmActionFailed = failed;
  445. setBusyState(el, false);
  446. }
  447. }
  448. // ---------------------------------------------------------------------------
  449. // Registration (module seam)
  450. // ---------------------------------------------------------------------------
  451. // Registration order mirrors the order these handlers had when processInner
  452. // called them directly; statum-markdown.js constrains its own handlers
  453. // relative to `stm-html`.
  454. bridge.registerAttribute('stm-class.', handleClass);
  455. bridge.registerAttribute('stm-attribute.', handleAttribute);
  456. bridge.registerAttribute('stm-disabled', handleDisabled);
  457. bridge.registerAttribute('stm-text', handleText);
  458. bridge.registerAttribute('stm-html', handleHtml);
  459. // Core's renderAll drives the walk defined in this module.
  460. bridge.installRenderDriver(function (scope) { render(document.body, scope); });
  461. })();