Bläddra i källkod

Generalize -from- bindings into registered observables

Per review feedback: the property mechanism is no longer a hard-coded value/checked special case. An observables table (property name -> the DOM events that signal its change) drives both the read path and the listener path; value and checked ship as defaults, and statum.binding.observe(name, events) registers further event-driven properties (e.g. selectedIndex with 'change', open with 'toggle') without module changes. A registered name the element lacks as a property falls back to attribute semantics via MutationObserver, so -from-{source} reads a property when one is observable and an attribute otherwise. Scenario 36 covers a custom registered observable updating via its event plus the attribute fallback; the module header and TEMPLATES/model/WHATS_NEW now describe -from-{source} with the observe API. Battery green: dom 36/36, split 8/8, markdown 96.4%.
saddle-ClankerSaddle 2 veckor sedan
förälder
incheckning
c84a302229
6 ändrade filer med 149 tillägg och 47 borttagningar
  1. 23 12
      TEMPLATES.md
  2. 13 5
      WHATS_NEW.md
  3. 71 24
      js/statum-binding.js
  4. 3 3
      model.md
  5. 2 2
      tests/dom/run.sh
  6. 37 1
      tests/dom/scenarios.js

+ 23 - 12
TEMPLATES.md

@@ -339,12 +339,13 @@ the variable reads as `undefined` for the pass — never a page crash.
   expression anywhere — including elements that precede its declaring
   element in document order (the pass-start sweep exposes lazy getters).
   Duplicate declarations: last in document order wins, one warn per pass.
-- **`stm-global-{name}-from-{attribute}`**: page-wide variable whose value
-  is the element's attribute; attribute changes trigger a redraw.
+- **`stm-global-{name}-from-{source}`**: page-wide variable sourced from an
+  element's property or attribute (see "observables" below); changes trigger
+  a redraw.
 - **`stm-local-{name}="expr"`**: subtree-scoped variable (element +
   descendants). Replaces the removed `stm-def-{name}-as`.
-- **`stm-local-{name}-from-{attribute}`**: subtree-scoped attribute-sourced
-  variable; attribute changes trigger a redraw.
+- **`stm-local-{name}-from-{source}`**: subtree-scoped sourced variable;
+  changes trigger a redraw.
 - **`stm-declare-{name}`**: declares `{name}` for a subtree WITHOUT a
   value, so a descendant binds it (`stm-local-{name}=…` or
   `stm-local-{name}-from-…`) and SIBLINGS of that binder can read it:
@@ -360,14 +361,24 @@ the variable reads as `undefined` for the pass — never a page crash.
 </div>
 ```
 
-**Property form**: `from-value` and `from-checked` on
-input/select/textarea read the live PROPERTY, not the attribute — an
-input/change listener updates the variable and redraws immediately (per
-keystroke). Every other attribute name (and every non-form-control element)
-uses a MutationObserver. REDRAW GUARD: an observed attribute change only
-redraws when the variable's derived value actually changed — the walk itself
-writes attributes (`stm-attribute.*`, `hidden`, `data-stm-active`), so a
-naive observer would loop forever.
+**Observables — how `-from-{source}` reads its source.** When `{source}`
+names a REGISTERED OBSERVABLE PROPERTY (defaults: `value`, `checked` — each
+with its standard `input`/`change` events) and the element has that
+property, the variable reads the live PROPERTY and listens to those events:
+every event updates the bound variables and redraws immediately (per
+keystroke for `value`). Further event-driven properties are registered
+without touching the module:
+
+```js
+statum.binding.observe('selectedIndex', ['change']);
+statum.binding.observe('open', ['toggle']);       // <details>
+```
+
+Every other name — and any registered name the element lacks as a property
+— falls back to ATTRIBUTE semantics via MutationObserver. REDRAW GUARD: an
+observed attribute change only redraws when the variable's derived value
+actually changed — the walk itself writes attributes (`stm-attribute.*`,
+`hidden`, `data-stm-active`), so a naive observer would loop forever.
 
 Declared-but-unbound variables read `undefined` (an expression like
 `x.length` then degrades to `undefined` for that expression via the

+ 13 - 5
WHATS_NEW.md

@@ -178,11 +178,19 @@ an error and read as `undefined` instead of crashing the page.
 </div>
 ```
 
-`-from-{attribute}` forms source the variable from an attribute
-(MutationObserver, redraw only when the value actually changes — the walk's
-own attribute writes never loop). On form controls `from-value` and
-`from-checked` read the live property instead, so typing updates the
-variable immediately. `stm-declare-{name}` reserves a name for a subtree so
+`-from-{source}` forms source the variable from a live property or
+attribute. When `{source}` names a registered observable property (`value`
+and `checked` ship by default) the binding reads the live property and
+listens to its change events — typing updates the variable and redraws per
+keystroke — and further event-driven properties register generically:
+
+```js
+statum.binding.observe('selectedIndex', ['change']);
+```
+
+Anything else is observed as an ATTRIBUTE via MutationObserver (redraw only
+when the value actually changes — the walk's own attribute writes never
+loop). `stm-declare-{name}` reserves a name for a subtree so
 a descendant can bind it and siblings of the binder can read it.
 
 **Migration**: `stm-def-{name}-as` still works as an exact obsolescent

+ 71 - 24
js/statum-binding.js

@@ -33,11 +33,14 @@
  *   binding logs '[statum] variable cycle: a → b → a' (variable names) and
  *   the variable reads as undefined for that pass. Distinct bindings that
  *   shadow a name are not cycles (an inner `x` may reference an outer `x`).
- * - `from-value` / `from-checked` on input/select/textarea read the live
- *   PROPERTY (not the attribute): an input/change listener updates the
- *   variable and calls renderAll() directly, per keystroke. Every other
- *   attribute name, and every non-form-control element, uses a
- *   MutationObserver.
+ * - OBSERVABLE PROPERTIES: `-from-{name}` where {name} is a registered
+ *   observable live property (defaults: `value`, `checked` — the events a
+ *   form control fires when they change) reads the PROPERTY and listens to
+ *   those events: each event updates the bound variables and calls
+ *   renderAll() directly. Further properties are registered with
+ *   `statum.binding.observe(name, events)` — no module changes needed.
+ *   Every other name (and any name the element lacks as a property) falls
+ *   back to ATTRIBUTE semantics via MutationObserver.
  * - Redraw guard: an observed attribute change only redraws when the
  *   variable's derived value actually changed — the walk itself writes
  *   attributes (stm-attribute.*, hidden, data-stm-active) and a naive
@@ -149,22 +152,43 @@
     return null;
   }
 
-  /** Live-property form: from-value/from-checked on a form control. */
-  function isPropSource(el, source) {
-    if (source !== 'value' && source !== 'checked') return false;
-    var tag = el.tagName;
-    return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA';
+  /**
+   * OBSERVABLE live properties: property name → the standard events that
+   * signal its change. This table — not hard-coded `if` checks — is what
+   * makes `-from-{property}` generic: `value` and `checked` ship by default
+   * (input/select/textarea fire `input`/`change`), and page code registers
+   * further event-driven properties via `statum.binding.observe()` instead
+   * of the module growing special cases.
+   * @type {Object<string, string[]>}
+   */
+  var observables = {
+    value: ['input', 'change'],
+    checked: ['input', 'change']
+  };
+
+  /**
+   * The events that make `name` observable on `el`, or null when `el` has
+   * no such property (fall back to attribute semantics — a `value` binding
+   * on a `<div>` observes the attribute, not a property the element lacks).
+   */
+  function observableFor(el, name) {
+    if (!Object.prototype.hasOwnProperty.call(observables, name)) return null;
+    if (!(name in el)) return null;
+    return observables[name];
   }
 
   /**
-   * Attach the redraw plumbing for one -from- binding. The listener/observer
-   * is attached ONCE per (element, source); the VARIABLE registration
-   * (propNames + seed) happens for every binding — several variables may
-   * bind the same source, and each must see updates (review finding V1).
+   * Attach the redraw plumbing for one -from- binding. For an observable
+   * property (see `observables`) the listeners are attached ONCE per
+   * (element, property); the VARIABLE registration (propNames + seed)
+   * happens for every binding — several variables may bind the same
+   * property, and each must see updates (review finding V1). Everything
+   * else observes the attribute via MutationObserver.
    */
   function bindSource(el, binding) {
     var propKey = binding.source;
-    if (isPropSource(el, propKey)) {
+    var events = observableFor(el, propKey);
+    if (events) {
       // Register this variable against the property (always).
       var names = propNames.get(el) || {};
       names[binding.name] = propKey;
@@ -174,7 +198,7 @@
         seed[binding.name] = el[propKey];
         liveProps.set(el, seed);
       }
-      // Attach the shared listener once per (element, property).
+      // Attach the shared listeners once per (element, property).
       var bound = propBound.get(el);
       if (bound && bound[propKey]) return;
       if (!bound) bound = {};
@@ -195,8 +219,9 @@
         if (changed) liveProps.set(el, store);
         if (changed) renderAll();
       };
-      el.addEventListener('input', onChange);
-      el.addEventListener('change', onChange);
+      for (var i = 0; i < events.length; i++) {
+        el.addEventListener(events[i], onChange);
+      }
       return;
     }
     var seen = observed.get(el);
@@ -228,12 +253,10 @@
     }
     return function () {
       bindSource(el, binding);
-      if (isPropSource(el, binding.source)) {
-        var store = liveProps.get(el);
-        if (store && Object.prototype.hasOwnProperty.call(store, binding.name)) {
-          return store[binding.name];
-        }
-      }
+      // Observable properties read the live property; everything else reads
+      // the attribute. Reading fresh keeps the value correct even between
+      // the last event and the next render.
+      if (observableFor(el, binding.source)) return el[binding.source];
       return el.getAttribute(binding.source);
     };
   }
@@ -355,4 +378,28 @@
   // --- installation -------------------------------------------------------
   bridge.buildScope(buildScope);
   bridge.onPassStart(passStart);
+
+  /**
+   * Public surface: register (or replace) an OBSERVABLE PROPERTY — a live
+   * element property that fires standard DOM events when it changes, so
+   * `-from-{name}` bindings read it as a property and listen to those
+   * events instead of observing the attribute. `value` and `checked` are
+   * registered by default; page code can extend the set:
+   *
+   *   statum.binding.observe('selectedIndex', ['change']);
+   *   statum.binding.observe('open', ['toggle']);
+   *
+   * @param {string} name property name
+   * @param {string[]} events events that signal the property changed
+   */
+  window.statum.binding = {
+    observe: function (name, events) {
+      if (typeof name !== 'string' || !name ||
+          !Array.isArray(events) || !events.length) {
+        console.warn('[statum] statum.binding.observe: expected a property name and a non-empty event list');
+        return;
+      }
+      observables[name] = events.slice();
+    }
+  };
 })();

+ 3 - 3
model.md

@@ -220,8 +220,8 @@ adding to the same `window.statum` global), served as default resources:
   `stm-markdown` binding, `stm-h-demotion`, and the programmatic
   `statum.markdown` API. Zero DOM dependencies in the engine itself.
 - `/_statum/resource/statum-binding.js` — client-side variables:
-  `stm-global-{name}`, `stm-global-{name}-from-{attribute}`,
-  `stm-local-{name}`, `stm-local-{name}-from-{attribute}` and
+  `stm-global-{name}`, `stm-global-{name}-from-{source}`,
+  `stm-local-{name}`, `stm-local-{name}-from-{source}` and
   `stm-declare-{name}` (see "Local definitions and client-side variables").
 
 **Core is headless**: it applies state and directives without touching the
@@ -557,7 +557,7 @@ The `stm-local-{name}` attribute (statum-binding.js) defines a variable bound to
 </div>
 ```
 
-`stm-global-{name}` declares a page-wide variable usable in any expression on the page (regardless of document order; duplicates resolve last-in-document-order with a warning). The `-from-{attribute}` forms source the variable from an element attribute instead of an expression — for `value`/`checked` on form controls the live PROPERTY is read (typing updates the variable and triggers an immediate redraw); other attributes use a MutationObserver and redraw only when the derived value actually changes. `stm-declare-{name}` declares a name for a subtree without a value so a descendant can bind it and siblings of that binder can read it. Referencing a variable that is part of an evaluation cycle logs an error naming the cycle and reads as `undefined` for that pass. The former `stm-def-{name}-as` attribute is kept as an exact obsolescent alias of `stm-local-{name}` (same semantics, one console.warn per element) so older templates keep working; it still requires the binding module. `statum-mkpstm` emits the modern `stm-local-{name}` form for fragment parameters.
+`stm-global-{name}` declares a page-wide variable usable in any expression on the page (regardless of document order; duplicates resolve last-in-document-order with a warning). The `-from-{source}` forms source the variable from a live property or an attribute instead of an expression: when `{source}` is a registered observable property (`value` and `checked` by default, each with its `input`/`change` events) the live PROPERTY is read and those events trigger an immediate redraw (typing updates the variable per keystroke); further properties register via `statum.binding.observe(name, events)`. Any other name — or a registered name the element lacks as a property — is observed as an ATTRIBUTE via MutationObserver, redrawing only when the derived value actually changes. `stm-declare-{name}` declares a name for a subtree without a value so a descendant can bind it and siblings of that binder can read it. Referencing a variable that is part of an evaluation cycle logs an error naming the cycle and reads as `undefined` for that pass. The former `stm-def-{name}-as` attribute is kept as an exact obsolescent alias of `stm-local-{name}` (same semantics, one console.warn per element) so older templates keep working; it still requires the binding module. `statum-mkpstm` emits the modern `stm-local-{name}` form for fragment parameters.
 
 ## Server-side attribute warnings
 

+ 2 - 2
tests/dom/run.sh

@@ -5,7 +5,7 @@
 # bootstrap as tests/markdown/run.sh), then runs each scenario in a fresh
 # context. Exits non-zero when any scenario fails.
 #
-# Usage: tests/dom/run.sh [scenario]   (default: all thirty-five)
+# Usage: tests/dom/run.sh [scenario]   (default: all thirty-six)
 set -e
 cd "$(dirname "$0")/../.."
 
@@ -19,7 +19,7 @@ else
   QJS=$(command -v qjs)
 fi
 
-SCENARIOS="${1:-1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35}"
+SCENARIOS="${1:-1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36}"
 
 for n in $SCENARIOS; do
   echo "== dom scenario $n =="

+ 37 - 1
tests/dom/scenarios.js

@@ -46,8 +46,9 @@
  * 33  declare-without-binder reads undefined; slot shadowing both ways
  * 34  stm-local replaces stm-def (demo's stamp pattern; stm-def inert)
  * 35  several -from- bindings on ONE element all update (review V1)
+ * 36  generic observables: statum.binding.observe + attribute fallback
  *
- * Usage: qjs tests/dom/scenarios.js <1..35>
+ * Usage: qjs tests/dom/scenarios.js <1..36>
  */
 import * as std from 'std';
 import { installShim } from './shim.js';
@@ -1039,6 +1040,41 @@ async function main() {
     host35.setAttribute('title', 'T2'); await spin(10);
     check(outT35.textContent === 'T2', 'first attribute observer stuck: ' + outT35.textContent);
 
+  } else if (scenario === '36') {
+    // Generic observables: a property registered through the public API
+    // (not hard-coded) drives -from- bindings with events, not attributes.
+    check(s.binding && typeof s.binding.observe === 'function',
+      'statum.binding.observe missing');
+    s.binding.observe('selectedIndex', ['change']);
+    var wrapSel = el('div'); wrapSel.setAttribute('stm-declare-sel', '');
+    var sel = el('select');
+    var o1 = el('option'); o1.setAttribute('value', 'a'); o1.value = 'a';
+    var o2 = el('option'); o2.setAttribute('value', 'b'); o2.value = 'b';
+    sel.appendChild(o1); sel.appendChild(o2);
+    // The shim's element accepts ad-hoc properties; options feed nothing —
+    // selectedIndex is ours to drive for the test.
+    sel.selectedIndex = 0;
+    sel.setAttribute('stm-local-sel-from-selectedIndex', '');
+    var outSel = el('span'); outSel.setAttribute('stm-text', 'sel');
+    wrapSel.appendChild(sel); wrapSel.appendChild(outSel);
+    body.appendChild(wrapSel);
+    await s._bridge.renderAll(); await spin();
+    check(outSel.textContent === '0', 'observable property not read: ' + outSel.textContent);
+    sel.selectedIndex = 1; fire(sel, 'change'); await spin(10);
+    check(outSel.textContent === '1',
+      'custom observable did not update via its event: ' + outSel.textContent);
+    // A registered property the element does NOT have falls back to the
+    // attribute (a real browser has no `value` property on a div; the shim
+    // does, so exercise the fallback with a property only the select had).
+    var wrap36 = el('div'); wrap36.setAttribute('stm-declare-v36', '');
+    var d36 = el('div'); d36.setAttribute('selectedIndex', 'attr');
+    d36.setAttribute('stm-local-v36-from-selectedIndex', '');
+    var out36 = el('span'); out36.setAttribute('stm-text', 'v36');
+    wrap36.appendChild(d36); wrap36.appendChild(out36);
+    body.appendChild(wrap36);
+    await s._bridge.renderAll(); await spin();
+    check(out36.textContent === 'attr', 'attribute fallback broken: ' + out36.textContent);
+
   } else {
     fail('unknown scenario: ' + scenario);
   }