statum-document.js 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  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. // Boot configuration must be in place before core's init() runs: the same
  950. // script tags that load this module also trigger core's autoInit on
  951. // DOMContentLoaded, and a synchronous page load has this module loaded by
  952. // then. (Direct statum.init() calls by embedding frameworks happen after
  953. // the module set loads, so the ordering holds there too.)
  954. readBootConfig();
  955. // Before the first render, so `hidden` wins over author CSS from the very
  956. // first paint.
  957. try { injectHiddenStyle(); } catch (e) {}
  958. try { warnServerSideMarkup(); } catch (e) {}
  959. })();