statum-forms.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /*!
  2. * Statum - client-side state library (forms module).
  3. *
  4. * Form-control bindings: `stm-prefill` (state -> control PROPERTY writes with
  5. * dirty-element tracking) and `stm-clear-on-success` (reset a form after its
  6. * own `stm-action` succeeds), plus the programmatic `statum.forms` API.
  7. *
  8. * stm-prefill writes properties, never attributes — that is its entire point.
  9. * `stm-attribute.value` keeps writing the value ATTRIBUTE (the server-wins
  10. * default-dirty path, issue C2); stm-prefill instead updates the control's
  11. * live `.value` / `.checked` / `.selected` properties so a prefill never
  12. * fights the user's typing. Elements the user has edited (dirty elements) are
  13. * never overwritten by a later render cycle.
  14. *
  15. * Radio groups are prefilled per-element: a radio carrying stm-prefill is
  16. * checked iff its own value matches the resolved value. Give every member of
  17. * the group the same stm-prefill expression and the group behaves correctly
  18. * (each member is checked iff its own value matches the resolved value).
  19. *
  20. * Load order: FOURTH - requires statum-core.js (`window.statum._bridge`).
  21. * The `stm-prefill` handler orders itself before statum-document.js's
  22. * `stm-class.*` so its property writes feed the controls before any
  23. * class/attribute predicate evaluates. Like core: an IIFE, no dependencies,
  24. * no build step, plain script tag.
  25. */
  26. (function () {
  27. 'use strict';
  28. if (!window.statum || !window.statum._bridge) {
  29. console.error('[statum] statum-forms.js requires statum-core.js');
  30. return;
  31. }
  32. var bridge = window.statum._bridge;
  33. var evalExpr = bridge.evalExpr;
  34. // ---------------------------------------------------------------------------
  35. // Dirty tracking
  36. // ---------------------------------------------------------------------------
  37. /**
  38. * Elements the user has edited since they were last reset. A dirty element
  39. * is skipped by stm-prefill: the user's edit outranks the state snapshot.
  40. *
  41. * Hosted on the bridge (not module-private) so task 186's reconciliation
  42. * work can reuse the same set instead of guessing dirtiness from DOM
  43. * comparison. Use `bridge.markDirty` / `bridge.isDirty`; do not poke the
  44. * WeakSet directly.
  45. * @type {WeakSet<Element>}
  46. */
  47. var formsDirty = new WeakSet();
  48. bridge.formsDirty = formsDirty;
  49. /** Mark an element dirty. @param {Element} el */
  50. function markDirty(el) { formsDirty.add(el); }
  51. /** Whether an element is dirty. @param {Element} el @returns {boolean} */
  52. function isDirty(el) { return formsDirty.has(el); }
  53. bridge.markDirty = markDirty;
  54. bridge.isDirty = isDirty;
  55. /**
  56. * Un-dirty a form and all its form controls (the form element itself is
  57. * rarely dirty — its inputs are — but clearing both is harmless).
  58. * @param {Element} form
  59. */
  60. function unmarkForm(form) {
  61. formsDirty.delete(form);
  62. var nodes = form.querySelectorAll('button,input,select,textarea,fieldset');
  63. for (var i = 0; i < nodes.length; i++) formsDirty.delete(nodes[i]);
  64. }
  65. // ---------------------------------------------------------------------------
  66. // Document-level listeners (capture phase)
  67. // ---------------------------------------------------------------------------
  68. /** Mark the element an input/change event targeted. @param {Event} ev */
  69. function onEdit(ev) {
  70. var t = ev.target;
  71. if (t && t.nodeType === 1) markDirty(t);
  72. }
  73. /**
  74. * The reset event fires BEFORE the browser restores default values (it is
  75. * cancelable), so un-dirtying during reset is correct: the values become
  76. * the defaults — clean, as far as statum is concerned — and the next
  77. * prefill cycle can re-apply state.
  78. *
  79. * Double duty: the server's `form-reset` directive calls form.reset(),
  80. * which fires this same reset event, so the directive un-dirties forms
  81. * through this one listener too.
  82. * @param {Event} ev
  83. */
  84. function onReset(ev) {
  85. var f = ev.target;
  86. if (f && f.nodeType === 1 && f.tagName === 'FORM') unmarkForm(f);
  87. }
  88. document.addEventListener('input', onEdit, true);
  89. document.addEventListener('change', onEdit, true);
  90. document.addEventListener('reset', onReset, true);
  91. // ---------------------------------------------------------------------------
  92. // stm-prefill
  93. // ---------------------------------------------------------------------------
  94. /** Input types whose value is plain text (property `value`). */
  95. var TEXTLIKE_TYPES = [
  96. 'text', 'email', 'number', 'password', 'search', 'tel', 'url',
  97. 'date', 'datetime-local', 'month', 'week', 'time', 'range', 'color', 'hidden'
  98. ];
  99. /**
  100. * Bind `stm-prefill`: write the resolved expression into the control's
  101. * properties (never attributes).
  102. *
  103. * Semantics:
  104. * - Dirty elements are skipped entirely — the user's edit outranks state.
  105. * - `<input type=file>` is skipped entirely: assigning `el.value` from a
  106. * script is forbidden by the platform (and silently no-ops), so there is
  107. * nothing sensible to write. No per-element warning; the file-wide
  108. * "unresolved leaves the DOM alone" policy applies.
  109. * - `undefined` (unresolved expression) leaves the element untouched.
  110. * - `null` is written as `''` for value writes and `false` for checked
  111. * writes.
  112. * - Runs every render cycle like every handler; property writes are
  113. * naturally idempotent per cycle.
  114. *
  115. * @param {Element} el
  116. * @param {object} scope
  117. */
  118. function handlePrefill(el, scope) {
  119. if (isDirty(el)) return;
  120. var type = el.type || (el.tagName === 'INPUT' ? 'text' : '');
  121. if (el.tagName === 'INPUT' && type === 'file') return;
  122. var value = evalExpr(el.getAttribute('stm-prefill'), scope);
  123. if (value === undefined) return;
  124. if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && TEXTLIKE_TYPES.indexOf(type) !== -1)) {
  125. el.value = value == null ? '' : String(value);
  126. return;
  127. }
  128. if (el.tagName === 'INPUT' && (type === 'checkbox')) {
  129. el.checked = !!value;
  130. return;
  131. }
  132. if (el.tagName === 'INPUT' && type === 'radio') {
  133. // Per-element: see the module header. A group is prefilled by giving
  134. // its members the same stm-prefill expression.
  135. el.checked = (el.value === String(value));
  136. return;
  137. }
  138. if (el.tagName === 'SELECT') {
  139. if (type === 'multiple' || el.multiple === true) {
  140. var arr = Array.isArray(value) ? value : [value];
  141. var options = el.querySelectorAll('option');
  142. for (var i = 0; i < options.length; i++) {
  143. var opt = options[i];
  144. opt.selected = arr.indexOf(String(opt.value)) !== -1;
  145. }
  146. } else {
  147. el.value = value == null ? '' : String(value);
  148. }
  149. return;
  150. }
  151. // Other elements (buttons, fieldsets, ...): the attribute is unsupported;
  152. // leave the element untouched rather than inventing a semantic.
  153. }
  154. // ---------------------------------------------------------------------------
  155. // stm-clear-on-success
  156. // ---------------------------------------------------------------------------
  157. /** Forms already warned about a missing stm-action. @type {WeakSet} */
  158. var warnSeen = new WeakSet();
  159. /**
  160. * Clear a form when its own stm-action succeeds. Registered on the document
  161. * (capture phase) so it sees every submit; the promise is stashed by
  162. * wireAction's target-phase listener on the form itself (el.__stmAction —
  163. * see statum-document.js).
  164. *
  165. * The stash is read in a MICROTASK: this capture-phase listener runs before
  166. * the form's target-phase listener has stashed the CURRENT action promise,
  167. * so a synchronous read would see the previous submit's promise (or none).
  168. * Event dispatch completes synchronously, so by the time the microtask runs
  169. * the stash always holds this submit's promise.
  170. *
  171. * `stm-clear-on-success` is supported on a FORM whose own stm-action
  172. * performs the request (wireAction wires a FORM for `submit`). If the form
  173. * carries the attribute but no stm-action of its own, there is no promise
  174. * to await, so the attribute is inert there; warn once per form.
  175. *
  176. * @param {Event} ev
  177. */
  178. function onSubmit(ev) {
  179. var f = ev.target;
  180. if (!f || f.nodeType !== 1 || f.tagName !== 'FORM') return;
  181. if (!(f.hasAttribute && f.hasAttribute('stm-clear-on-success'))) return;
  182. Promise.resolve().then(function () {
  183. var promise = f.__stmAction;
  184. if (!promise) {
  185. if (!warnSeen.has(f)) {
  186. warnSeen.add(f);
  187. console.warn('[statum] stm-clear-on-success on a form without stm-action does nothing; ' +
  188. 'put stm-action on the form itself');
  189. }
  190. return;
  191. }
  192. promise.then(function () {
  193. // runElementAction resolves even when the action failed (it swallows
  194. // the rejection so it does not surface as unhandled), so success is
  195. // read from its outcome flag — see statum-document.js.
  196. if (f.__stmActionFailed === false) f.reset();
  197. }, function () {
  198. /* failure: leave the fields as they are */
  199. });
  200. });
  201. }
  202. document.addEventListener('submit', onSubmit, true);
  203. // ---------------------------------------------------------------------------
  204. // Programmatic API
  205. // ---------------------------------------------------------------------------
  206. /**
  207. * Resolve the form an element belongs to: the element itself if it is a
  208. * form, `el.form` where available (form controls), else the closest form
  209. * ancestor.
  210. * @param {Element} el
  211. * @returns {Element|null}
  212. */
  213. function resolveForm(el) {
  214. if (!el) return null;
  215. if (el.tagName === 'FORM') return el;
  216. if (el.form) return el.form;
  217. if (typeof el.closest === 'function') return el.closest('form');
  218. return null;
  219. }
  220. /** Reset exactly one form (the reset event un-dirties it). @param {Element} formOrEl */
  221. function resetForm(formOrEl) {
  222. var f = resolveForm(formOrEl);
  223. if (f) f.reset();
  224. }
  225. window.statum.forms = {
  226. /** Reset one form (or the form owning an element). */
  227. reset: resetForm,
  228. /** Mark an element dirty (skipped by stm-prefill until reset). */
  229. markDirty: markDirty,
  230. /** Whether an element is dirty. */
  231. isDirty: isDirty
  232. };
  233. // ---------------------------------------------------------------------------
  234. // Registration (module seam)
  235. // ---------------------------------------------------------------------------
  236. // Before stm-class.*: the property writes feed the controls before any
  237. // class/attribute predicate evaluates.
  238. bridge.registerAttribute('stm-prefill', handlePrefill, { before: 'stm-class.' });
  239. })();