|
|
@@ -8,19 +8,28 @@
|
|
|
*
|
|
|
* stm-global-{name}="expr" page-wide variable, usable in any
|
|
|
* expression anywhere on the page
|
|
|
- * stm-global-{name}-from-{attribute} page-wide variable sourced from an
|
|
|
- * element attribute; changes to that
|
|
|
- * attribute trigger a redraw
|
|
|
+ * stm-global-{name}-from-{source} page-wide variable sourced from a
|
|
|
+ * live property (event-mapped) or,
|
|
|
+ * with the explicit spelling
|
|
|
+ * -from-attribute.{name}, an
|
|
|
+ * attribute; changes trigger a redraw
|
|
|
* stm-local-{name}="expr" subtree-scoped variable (element +
|
|
|
* descendants); supersedes
|
|
|
* stm-def-{name}-as (kept as an
|
|
|
* obsolescent alias, see below)
|
|
|
- * stm-local-{name}-from-{attribute} subtree-scoped attribute-sourced
|
|
|
- * variable; changes trigger a redraw
|
|
|
+ * stm-local-{name}-from-{source} subtree-scoped sourced variable;
|
|
|
+ * same two spellings as the global
|
|
|
+ * form
|
|
|
* stm-declare-{name} declares {name} for a subtree so a
|
|
|
* descendant can bind it and SIBLINGS
|
|
|
* of that binder can read it
|
|
|
*
|
|
|
+ * stm-update-attribute.{name}="expr" set the attribute ONLY when the
|
|
|
+ * expression's evaluated value
|
|
|
+ * differs from what this binding
|
|
|
+ * last wrote (local/scripted changes
|
|
|
+ * survive between renders)
|
|
|
+ *
|
|
|
* The legacy `stm-def-{name}-as="expr"` spelling (the pre-binding-module
|
|
|
* form, still emitted by older templates) is accepted as an exact alias of
|
|
|
* `stm-local-{name}` and logs a one-time obsolescence warning per element.
|
|
|
@@ -130,8 +139,10 @@
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Parse a binding attribute name into {kind, name, source, attr};
|
|
|
- * source is the attribute/property name for -from- forms, else null.
|
|
|
+ * Parse a binding attribute name into {kind, name, source, attr, mode};
|
|
|
+ * mode is 'property' for `-from-{property}` (a registered observable) or
|
|
|
+ * 'attribute' for `-from-attribute.{name}` (explicit, aligned with
|
|
|
+ * `stm-attribute.{name}`). Expression forms have source null.
|
|
|
* `stm-def-{name}-as` (the pre-binding spelling) parses as a LOCAL
|
|
|
* expression binding with legacy=true: identical semantics, plus an
|
|
|
* obsolescence warning at bind time.
|
|
|
@@ -139,12 +150,16 @@
|
|
|
*/
|
|
|
function parseBinding(attr) {
|
|
|
var m;
|
|
|
- if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-(.+)$/)))
|
|
|
- return { kind: 'global', name: m[1], source: m[2], attr: attr };
|
|
|
+ if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-attribute\.([\w$:-]+)$/)))
|
|
|
+ return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
|
|
|
+ if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
|
|
|
+ return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'property' };
|
|
|
if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)$/)))
|
|
|
return { kind: 'global', name: m[1], source: null, attr: attr };
|
|
|
- if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-(.+)$/)))
|
|
|
- return { kind: 'local', name: m[1], source: m[2], attr: attr };
|
|
|
+ if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-attribute\.([\w$:-]+)$/)))
|
|
|
+ return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
|
|
|
+ if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
|
|
|
+ return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'property' };
|
|
|
if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)$/)))
|
|
|
return { kind: 'local', name: m[1], source: null, attr: attr };
|
|
|
if ((m = attr.match(/^stm-def-([A-Za-z_$][\w$]*)-as$/)))
|
|
|
@@ -155,10 +170,11 @@
|
|
|
/**
|
|
|
* 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.
|
|
|
+ * makes `-from-{property}` (and the attribute-sourced spelling, for any
|
|
|
+ * name the mapping covers) 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 = {
|
|
|
@@ -167,9 +183,11 @@
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
- * 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).
|
|
|
+ * The events that make `name` observable on `el`, or null when the
|
|
|
+ * property is not registered or `el` lacks it. Consulted for BOTH
|
|
|
+ * spellings: `-from-{property}` and `-from-attribute.{name}` — the
|
|
|
+ * event mapping is the primary observation mechanism for any name it
|
|
|
+ * covers; the MutationObserver supplements/fallbacks for it.
|
|
|
*/
|
|
|
function observableFor(el, name) {
|
|
|
if (!Object.prototype.hasOwnProperty.call(observables, name)) return null;
|
|
|
@@ -178,18 +196,20 @@
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 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.
|
|
|
+ * Attach the redraw plumbing for one -from- binding. The EVENT MAPPING
|
|
|
+ * is primary: whenever the source name is a registered observable on the
|
|
|
+ * element (either spelling — `-from-{property}` or
|
|
|
+ * `-from-attribute.{name}`), listeners on the mapped events update the
|
|
|
+ * bound variables and redraw. The MutationObserver supplements (and
|
|
|
+ * covers every non-observable name) so scripted attribute writes are
|
|
|
+ * never missed. Listeners attach once per (element, property); the
|
|
|
+ * VARIABLE registration happens for every binding (several variables may
|
|
|
+ * bind one source — review finding V1).
|
|
|
*/
|
|
|
function bindSource(el, binding) {
|
|
|
var propKey = binding.source;
|
|
|
var events = observableFor(el, propKey);
|
|
|
if (events) {
|
|
|
- // Register this variable against the property (always).
|
|
|
var names = propNames.get(el) || {};
|
|
|
names[binding.name] = propKey;
|
|
|
propNames.set(el, names);
|
|
|
@@ -198,31 +218,32 @@
|
|
|
seed[binding.name] = el[propKey];
|
|
|
liveProps.set(el, seed);
|
|
|
}
|
|
|
- // Attach the shared listeners once per (element, property).
|
|
|
var bound = propBound.get(el);
|
|
|
- if (bound && bound[propKey]) return;
|
|
|
if (!bound) bound = {};
|
|
|
- bound[propKey] = true;
|
|
|
- propBound.set(el, bound);
|
|
|
- var onChange = function () {
|
|
|
- var n = propNames.get(el);
|
|
|
- if (!n) return;
|
|
|
- var changed = false;
|
|
|
- var store = liveProps.get(el) || {};
|
|
|
- for (var name in n) {
|
|
|
- var key = n[name];
|
|
|
- if (store[name] !== el[key]) {
|
|
|
- store[name] = el[key];
|
|
|
- changed = true;
|
|
|
+ if (!bound[propKey]) {
|
|
|
+ bound[propKey] = true;
|
|
|
+ propBound.set(el, bound);
|
|
|
+ var onChange = function () {
|
|
|
+ var n = propNames.get(el);
|
|
|
+ if (!n) return;
|
|
|
+ var changed = false;
|
|
|
+ var store = liveProps.get(el) || {};
|
|
|
+ for (var name in n) {
|
|
|
+ var key = n[name];
|
|
|
+ if (store[name] !== el[key]) {
|
|
|
+ store[name] = el[key];
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
}
|
|
|
+ if (changed) liveProps.set(el, store);
|
|
|
+ if (changed) renderAll();
|
|
|
+ };
|
|
|
+ for (var i = 0; i < events.length; i++) {
|
|
|
+ el.addEventListener(events[i], onChange);
|
|
|
}
|
|
|
- if (changed) liveProps.set(el, store);
|
|
|
- if (changed) renderAll();
|
|
|
- };
|
|
|
- for (var i = 0; i < events.length; i++) {
|
|
|
- el.addEventListener(events[i], onChange);
|
|
|
}
|
|
|
- return;
|
|
|
+ // An observable source may still be written as an attribute; keep
|
|
|
+ // the observer as a supplementary signal (unless already attached).
|
|
|
}
|
|
|
var seen = observed.get(el);
|
|
|
if (seen && seen[propKey]) return;
|
|
|
@@ -230,15 +251,31 @@
|
|
|
if (!seen) seen = {};
|
|
|
seen[propKey] = true;
|
|
|
observed.set(el, seen);
|
|
|
- var last = el.getAttribute(propKey);
|
|
|
+ var last = attrSnapshot(el, propKey);
|
|
|
new MutationObserver(function () {
|
|
|
- var now = el.getAttribute(propKey);
|
|
|
+ var now = attrSnapshot(el, propKey);
|
|
|
if (now === last) return; // same-value write: never redraw
|
|
|
last = now;
|
|
|
renderAll();
|
|
|
}).observe(el, { attributes: true, attributeFilter: [propKey] });
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Read a source's current value the way the binding should present it:
|
|
|
+ * the live PROPERTY when one is observable on the element, else the
|
|
|
+ * attribute. (For an observable property the attribute is a stale
|
|
|
+ * default; for a plain attribute there is no property to read.)
|
|
|
+ */
|
|
|
+ function readSource(el, source) {
|
|
|
+ return observableFor(el, source) ? el[source] : el.getAttribute(source);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Snapshot a source attribute: the string value, or null when absent. */
|
|
|
+ function attrSnapshot(el, name) {
|
|
|
+ var v = el.getAttribute(name);
|
|
|
+ return v == null ? null : String(v);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Build one binding's value computation. Expression forms evaluate
|
|
|
* against the PARENT scope (a binding cannot reference itself or a
|
|
|
@@ -256,8 +293,7 @@
|
|
|
// 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);
|
|
|
+ return readSource(el, binding.source);
|
|
|
};
|
|
|
}
|
|
|
|
|
|
@@ -379,6 +415,61 @@
|
|
|
bridge.buildScope(buildScope);
|
|
|
bridge.onPassStart(passStart);
|
|
|
|
|
|
+ /**
|
|
|
+ * `stm-update-attribute.{name}="expr"` — set the attribute ONLY when the
|
|
|
+ * expression's evaluated value differs from the last value THIS binding
|
|
|
+ * wrote (stored separately from the DOM). Unlike `stm-attribute.{name}`
|
|
|
+ * (which writes every cycle and would clobber user/script changes made
|
|
|
+ * between renders), an interaction or script may change the attribute and
|
|
|
+ * the binding leaves it alone until the expression's own output actually
|
|
|
+ * changes. Example: an expander whose `open` state is server-driven but
|
|
|
+ * user-toggled locally — the server's value lands once, then local
|
|
|
+ * toggles survive until the state genuinely changes.
|
|
|
+ *
|
|
|
+ * Coercion: `undefined`/`null` remove the attribute; booleans set/remove
|
|
|
+ * it; anything else is stringified. The first evaluation establishes the
|
|
|
+ * baseline (it writes only if the attribute does not already match).
|
|
|
+ */
|
|
|
+ var lastWritten = new WeakMap(); // element -> { attrName: lastValue|null }
|
|
|
+
|
|
|
+ function handleUpdateAttribute(el, scope) {
|
|
|
+ var attrs = el.attributes;
|
|
|
+ for (var i = 0; i < attrs.length; i++) {
|
|
|
+ var attr = attrs[i];
|
|
|
+ if (attr.name.indexOf('stm-update-attribute.') !== 0) continue;
|
|
|
+ var target = attr.name.slice('stm-update-attribute.'.length);
|
|
|
+ if (!target) continue;
|
|
|
+ var value = evalExpr(attr.value, scope);
|
|
|
+ var memo = lastWritten.get(el) || {};
|
|
|
+ var key = target;
|
|
|
+ var next; // undefined => leave untouched, null => remove
|
|
|
+ if (value === undefined) continue; // unresolved: never touch
|
|
|
+ if (value === null || value === false) next = null;
|
|
|
+ else if (value === true) next = '';
|
|
|
+ else next = String(value);
|
|
|
+ if (Object.prototype.hasOwnProperty.call(memo, key)) {
|
|
|
+ if (memo[key] === next) continue; // our own value: do not rewrite
|
|
|
+ } else {
|
|
|
+ // First evaluation: if the attribute already holds this value,
|
|
|
+ // adopt it as the baseline without writing.
|
|
|
+ var current = el.getAttribute(target);
|
|
|
+ var currentNorm = current == null ? null : String(current);
|
|
|
+ if (currentNorm === (next === null ? null : next)) {
|
|
|
+ memo[key] = next;
|
|
|
+ lastWritten.set(el, memo);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ memo[key] = next;
|
|
|
+ lastWritten.set(el, memo);
|
|
|
+ if (next === null) el.removeAttribute(target);
|
|
|
+ else el.setAttribute(target, next);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ window.statum.registerAttribute('stm-update-attribute.', handleUpdateAttribute,
|
|
|
+ { before: 'stm-class.' });
|
|
|
+
|
|
|
/**
|
|
|
* Public surface: register (or replace) an OBSERVABLE PROPERTY — a live
|
|
|
* element property that fires standard DOM events when it changes, so
|