| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- /*!
- * 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<Element>}
- */
- 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.
- * - `<input type=file>` 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.' });
- })();
|