/*! * Statum - client-side state library (forms module). * * Form-control bindings: `stm-prefill` (state -> control PROPERTY writes with * dirty-element tracking) and `stm-clear-on-success` (reset a form after its * own `stm-action` succeeds), plus the programmatic `statum.forms` API. * * stm-prefill writes properties, never attributes — that is its entire point. * `stm-attribute.value` keeps writing the value ATTRIBUTE (the server-wins * default-dirty path, issue C2); stm-prefill instead updates the control's * live `.value` / `.checked` / `.selected` properties so a prefill never * fights the user's typing. Elements the user has edited (dirty elements) are * never overwritten by a later render cycle. * * Radio groups are prefilled per-element: a radio carrying stm-prefill is * checked iff its own value matches the resolved value. Give every member of * the group the same stm-prefill expression and the group behaves correctly * (each member is checked iff its own value matches the resolved value). * * Load order: FOURTH - requires statum-core.js (`window.statum._bridge`). * The `stm-prefill` handler orders itself before statum-document.js's * `stm-class.*` so its property writes feed the controls before any * class/attribute predicate evaluates. Like core: an IIFE, no dependencies, * no build step, plain script tag. */ (function () { 'use strict'; if (!window.statum || !window.statum._bridge) { console.error('[statum] statum-forms.js requires statum-core.js'); return; } var bridge = window.statum._bridge; var evalExpr = bridge.evalExpr; // --------------------------------------------------------------------------- // Dirty tracking // --------------------------------------------------------------------------- /** * Elements the user has edited since they were last reset. A dirty element * is skipped by stm-prefill: the user's edit outranks the state snapshot. * * Hosted on the bridge (not module-private) so task 186's reconciliation * work can reuse the same set instead of guessing dirtiness from DOM * comparison. Use `bridge.markDirty` / `bridge.isDirty`; do not poke the * WeakSet directly. * @type {WeakSet} */ var formsDirty = new WeakSet(); bridge.formsDirty = formsDirty; /** Mark an element dirty. @param {Element} el */ function markDirty(el) { formsDirty.add(el); } /** Whether an element is dirty. @param {Element} el @returns {boolean} */ function isDirty(el) { return formsDirty.has(el); } bridge.markDirty = markDirty; bridge.isDirty = isDirty; /** * Un-dirty a form and all its form controls (the form element itself is * rarely dirty — its inputs are — but clearing both is harmless). * @param {Element} form */ function unmarkForm(form) { formsDirty.delete(form); var nodes = form.querySelectorAll('button,input,select,textarea,fieldset'); for (var i = 0; i < nodes.length; i++) formsDirty.delete(nodes[i]); } // --------------------------------------------------------------------------- // Document-level listeners (capture phase) // --------------------------------------------------------------------------- /** Mark the element an input/change event targeted. @param {Event} ev */ function onEdit(ev) { var t = ev.target; if (t && t.nodeType === 1) markDirty(t); } /** * The reset event fires BEFORE the browser restores default values (it is * cancelable), so un-dirtying during reset is correct: the values become * the defaults — clean, as far as statum is concerned — and the next * prefill cycle can re-apply state. * * Double duty: the server's `form-reset` directive calls form.reset(), * which fires this same reset event, so the directive un-dirties forms * through this one listener too. * @param {Event} ev */ function onReset(ev) { var f = ev.target; if (f && f.nodeType === 1 && f.tagName === 'FORM') unmarkForm(f); } document.addEventListener('input', onEdit, true); document.addEventListener('change', onEdit, true); document.addEventListener('reset', onReset, true); // --------------------------------------------------------------------------- // stm-prefill // --------------------------------------------------------------------------- /** Input types whose value is plain text (property `value`). */ var TEXTLIKE_TYPES = [ 'text', 'email', 'number', 'password', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'week', 'time', 'range', 'color', 'hidden' ]; /** * Bind `stm-prefill`: write the resolved expression into the control's * properties (never attributes). * * Semantics: * - Dirty elements are skipped entirely — the user's edit outranks state. * - `` is skipped entirely: assigning `el.value` from a * script is forbidden by the platform (and silently no-ops), so there is * nothing sensible to write. No per-element warning; the file-wide * "unresolved leaves the DOM alone" policy applies. * - `undefined` (unresolved expression) leaves the element untouched. * - `null` is written as `''` for value writes and `false` for checked * writes. * - Runs every render cycle like every handler; property writes are * naturally idempotent per cycle. * * @param {Element} el * @param {object} scope */ function handlePrefill(el, scope) { if (isDirty(el)) return; var type = el.type || (el.tagName === 'INPUT' ? 'text' : ''); if (el.tagName === 'INPUT' && type === 'file') return; var value = evalExpr(el.getAttribute('stm-prefill'), scope); if (value === undefined) return; if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && TEXTLIKE_TYPES.indexOf(type) !== -1)) { el.value = value == null ? '' : String(value); return; } if (el.tagName === 'INPUT' && (type === 'checkbox')) { el.checked = !!value; return; } if (el.tagName === 'INPUT' && type === 'radio') { // Per-element: see the module header. A group is prefilled by giving // its members the same stm-prefill expression. el.checked = (el.value === String(value)); return; } if (el.tagName === 'SELECT') { if (type === 'multiple' || el.multiple === true) { var arr = Array.isArray(value) ? value : [value]; var options = el.querySelectorAll('option'); for (var i = 0; i < options.length; i++) { var opt = options[i]; opt.selected = arr.indexOf(String(opt.value)) !== -1; } } else { el.value = value == null ? '' : String(value); } return; } // Other elements (buttons, fieldsets, ...): the attribute is unsupported; // leave the element untouched rather than inventing a semantic. } // --------------------------------------------------------------------------- // stm-clear-on-success // --------------------------------------------------------------------------- /** Forms already warned about a missing stm-action. @type {WeakSet} */ var warnSeen = new WeakSet(); /** * Clear a form when its own stm-action succeeds. Registered on the document * (capture phase) so it sees every submit; the promise is stashed by * wireAction's target-phase listener on the form itself (el.__stmAction — * see statum-document.js). * * The stash is read in a MICROTASK: this capture-phase listener runs before * the form's target-phase listener has stashed the CURRENT action promise, * so a synchronous read would see the previous submit's promise (or none). * Event dispatch completes synchronously, so by the time the microtask runs * the stash always holds this submit's promise. * * `stm-clear-on-success` is supported on a FORM whose own stm-action * performs the request (wireAction wires a FORM for `submit`). If the form * carries the attribute but no stm-action of its own, there is no promise * to await, so the attribute is inert there; warn once per form. * * @param {Event} ev */ function onSubmit(ev) { var f = ev.target; if (!f || f.nodeType !== 1 || f.tagName !== 'FORM') return; if (!(f.hasAttribute && f.hasAttribute('stm-clear-on-success'))) return; Promise.resolve().then(function () { var promise = f.__stmAction; if (!promise) { if (!warnSeen.has(f)) { warnSeen.add(f); console.warn('[statum] stm-clear-on-success on a form without stm-action does nothing; ' + 'put stm-action on the form itself'); } return; } promise.then(function () { // runElementAction resolves even when the action failed (it swallows // the rejection so it does not surface as unhandled), so success is // read from its outcome flag — see statum-document.js. if (f.__stmActionFailed === false) f.reset(); }, function () { /* failure: leave the fields as they are */ }); }); } document.addEventListener('submit', onSubmit, true); // --------------------------------------------------------------------------- // Programmatic API // --------------------------------------------------------------------------- /** * Resolve the form an element belongs to: the element itself if it is a * form, `el.form` where available (form controls), else the closest form * ancestor. * @param {Element} el * @returns {Element|null} */ function resolveForm(el) { if (!el) return null; if (el.tagName === 'FORM') return el; if (el.form) return el.form; if (typeof el.closest === 'function') return el.closest('form'); return null; } /** Reset exactly one form (the reset event un-dirties it). @param {Element} formOrEl */ function resetForm(formOrEl) { var f = resolveForm(formOrEl); if (f) f.reset(); } window.statum.forms = { /** Reset one form (or the form owning an element). */ reset: resetForm, /** Mark an element dirty (skipped by stm-prefill until reset). */ markDirty: markDirty, /** Whether an element is dirty. */ isDirty: isDirty }; // --------------------------------------------------------------------------- // Registration (module seam) // --------------------------------------------------------------------------- // Before stm-class.*: the property writes feed the controls before any // class/attribute predicate evaluates. bridge.registerAttribute('stm-prefill', handlePrefill, { before: 'stm-class.' }); })();