From 0aaf81b1ea0a212ca2f6cf3aa79a8c6b5b3aafc1 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 3 Jun 2026 23:12:02 +0200 Subject: [PATCH 1/3] feat(core signals): Add a TC39 signals polyfill. The TC39 signals specification for data binding. See: https://github.com/tc39/proposal-signals Uses: https://github.com/proposal-signals/signal-polyfill --- jest.config.js | 5 + package.json | 1 + src/core/signals.js | 139 ++++++++++++++++ src/core/signals.md | 68 ++++++++ src/core/signals.test.js | 339 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 552 insertions(+) create mode 100644 src/core/signals.js create mode 100644 src/core/signals.md create mode 100644 src/core/signals.test.js diff --git a/jest.config.js b/jest.config.js index 41aa9692c..36d8f0182 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,4 +5,9 @@ config.setupFilesAfterEnv.push(path.resolve(__dirname, "./src/setup-tests.js")); config.moduleNameMapper["@patternslib/patternslib/(.*)"] = path.resolve(__dirname) + "/$1"; +// The official signal polyfill ships ESM and needs Babel in Jest. +config.transformIgnorePatterns = config.transformIgnorePatterns.map((pattern) => + pattern.replace("preact/|", "preact/|signal-polyfill/|") +); + module.exports = config; diff --git a/package.json b/package.json index 966a48954..0aab97aa4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "select2": "^3.5.1", "showdown": "^2.1.0", "showdown-prettify": "^1.3.0", + "signal-polyfill": "^0.2.2", "slick-carousel": "git+https://github.com/kenwheeler/slick.git#d0716f19aa730006ee80ab026625fb1107816a97", "spectrum-colorpicker": "^1.8.0", "tippy.js": "^6.3.7" diff --git a/src/core/signals.js b/src/core/signals.js new file mode 100644 index 000000000..c211046e3 --- /dev/null +++ b/src/core/signals.js @@ -0,0 +1,139 @@ +/** + * Patternslib helpers backed by the TC39 Signals proposal's official polyfill. + * Computed values are lazy; effects run immediately, then in microtask batches. + */ +import { Signal } from "signal-polyfill"; +import logging from "./logging"; + +const log = logging.getLogger("signals"); +const pending_effects = new Set(); +const MAX_RUNS_PER_EFFECT = 1000; +let flush_scheduled = false; +let batch_depth = 0; + +function schedule_flush() { + if (flush_scheduled || batch_depth > 0 || !pending_effects.size) { + return; + } + flush_scheduled = true; + queueMicrotask(flush_effects); +} + +function flush_effects() { + const run_counts = new Map(); + try { + for (const eff of pending_effects) { + pending_effects.delete(eff); + const runs = (run_counts.get(eff) || 0) + 1; + if (runs > MAX_RUNS_PER_EFFECT) { + pending_effects.clear(); + log.error( + "Aborting effect flush after too many iterations - likely a cyclic dependency." + ); + return; + } + run_counts.set(eff, runs); + eff.run(); + } + } finally { + flush_scheduled = false; + } +} + +/** Create a writable value with tracked reads and untracked peek(). */ +function signal(initial_value) { + const state = new Signal.State(initial_value); + return { + get value() { + return state.get(); + }, + set value(value) { + state.set(value); + }, + peek() { + return untracked(() => state.get()); + }, + }; +} + +/** + * Create a lazy, cached derivation. Reads always return the current value. + * dispose() freezes the last evaluated value (undefined if never evaluated). + * Disposal is optional for unused computeds, which are not kept live by sources. + */ +function computed(fn) { + const active = new Signal.State(true); + let value; + const derived = new Signal.Computed(() => { + if (active.get()) { + value = fn(); + } + return value; + }); + return { + get value() { + return derived.get(); + }, + peek() { + return untracked(() => derived.get()); + }, + dispose() { + active.set(false); + fn = null; + // Drop source dependencies now, even if nobody reads again. + untracked(() => derived.get()); + }, + }; +} + +/** Run immediately and after dependency changes; return a dispose function. */ +function effect(fn) { + let disposed = false; + const derived = new Signal.Computed(() => { + try { + fn(); + } catch (e) { + log.error("Error while running effect.", e); + } + }); + const eff = { + run() { + if (disposed) return; + // Re-arm before evaluation so writes can schedule other effects. + watcher.watch(); + derived.get(); + }, + }; + const watcher = new Signal.subtle.Watcher(() => { + // Watcher callbacks must not read or write signals. + pending_effects.add(eff); + schedule_flush(); + }); + watcher.watch(derived); + // Creating an effect inside another computation must not subscribe it. + untracked(() => eff.run()); + return () => { + disposed = true; + watcher.unwatch(derived); + pending_effects.delete(eff); + }; +} + +/** Group synchronous writes, deferring effect scheduling until the end. */ +function batch(fn) { + batch_depth++; + try { + return fn(); + } finally { + batch_depth--; + schedule_flush(); + } +} + +/** Run without subscribing the surrounding computation to signal reads. */ +function untracked(fn) { + return Signal.subtle.untrack(fn); +} + +export { signal, computed, effect, batch, untracked }; +export default { signal, computed, effect, batch, untracked }; diff --git a/src/core/signals.md b/src/core/signals.md new file mode 100644 index 000000000..1d6436b30 --- /dev/null +++ b/src/core/signals.md @@ -0,0 +1,68 @@ +# Signals + +Reactive values backed by the official [TC39 Signals polyfill](https://github.com/proposal-signals/signal-polyfill), +with automatic dependency tracking. Used by +[pat-bind](../pat/bind/documentation.md) to keep DOM elements in sync. + +## Usage + +```javascript +import { + signal, + computed, + effect, + batch, +} from "@patternslib/patternslib/src/core/signals"; + +const price = signal(10); +const quantity = signal(2); +const total = computed(() => price.value * quantity.value); +const dispose = effect(() => console.log(total.value)); // Logs 20 immediately. + +batch(() => { + price.value = 12; + quantity.value = 3; +}); + +await Promise.resolve(); // The queued update logs 36 once. + +// In a pattern, perform this cleanup in destroy(). +dispose(); +// Unused computed values need no disposal. +``` + +## API + +- `signal(value)` creates a writable value. Read and assign through `.value`. + Assigning an equal value (`Object.is`) does not trigger updates. +- `computed(fn)` creates a lazy, cached, read-only derived value. Keep `fn` + free of side effects. Unused computeds need no disposal. For compatibility, + `.dispose()` freezes the last evaluated value and releases its dependencies; + disposing before the first read freezes `undefined`. Computation errors are + thrown on reads and cached until a dependency changes. +- `effect(fn)` runs immediately and tracks signals read synchronously by `fn`. + It runs again when its dependencies change. The returned function stops it. +- `batch(fn)` groups synchronous writes, deferring scheduling until it returns. + Synchronous writes are also batched automatically. +- `untracked(fn)` reads without collecting dependencies. Signals and computed + values also provide `.peek()` for a single untracked read. + +Effects are batched in a microtask. Computed values evaluate only when read, +and both `.value` and `.peek()` return an up-to-date result immediately after +a write, including inside `batch()`. Computed reads update their dependencies +before returning, so consumers see consistent derived values. + +Effects may write signals, but should not rely on repeatedly writing their own +dependencies to schedule themselves. Use computed values for derivations. + +## Specifications + +This module wraps `signal-polyfill`, the official implementation of the +[TC39 Signals proposal](https://github.com/tc39/proposal-signals). +`signal()` and `computed()` adapt `Signal.State` and `Signal.Computed` to the +Patternslib `.value` / `.peek()` API. `untracked()` delegates to +`Signal.subtle.untrack()`. Effects use `Signal.subtle.Watcher`; their microtask +scheduling and the `batch()` helper are Patternslib APIs. + +Scheduling uses `queueMicrotask`, defined by the +[HTML Standard](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#microtask-queuing). diff --git a/src/core/signals.test.js b/src/core/signals.test.js new file mode 100644 index 000000000..117c4adf4 --- /dev/null +++ b/src/core/signals.test.js @@ -0,0 +1,339 @@ +import { signal, computed, effect, batch, untracked } from "./signals"; + +// A microtask flush helper: effects are batched and run in a microtask. +const flush = () => Promise.resolve(); + +describe("core/signals", function () { + describe("1 - signal", function () { + it("1.1 - stores and returns its value", function () { + const count = signal(1); + expect(count.value).toBe(1); + count.value = 2; + expect(count.value).toBe(2); + }); + + it("1.2 - peek() reads without subscribing", async function () { + const count = signal(1); + let runs = 0; + effect(() => { + runs++; + count.peek(); + }); + await flush(); + expect(runs).toBe(1); + + count.value = 2; + await flush(); + // The effect did not subscribe via peek(), so it does not re-run. + expect(runs).toBe(1); + }); + }); + + describe("2 - effect", function () { + it("does not run an effect disposed while its update is pending", async function () { + const source = signal(0); + const read = jest.fn(() => source.value); + const dispose = effect(read); + source.value = 1; + dispose(); + dispose(); + await flush(); + expect(read).toHaveBeenCalledTimes(1); + }); + + it("2.1 - runs immediately and on dependency change", async function () { + const count = signal(1); + const seen = []; + effect(() => seen.push(count.value)); + + // Effect runs synchronously on creation. + expect(seen).toEqual([1]); + + count.value = 2; + await flush(); + expect(seen).toEqual([1, 2]); + }); + + it("2.2 - coalesces multiple writes into one re-run", async function () { + const count = signal(0); + let runs = 0; + effect(() => { + runs++; + count.value; + }); + expect(runs).toBe(1); + + count.value = 1; + count.value = 2; + count.value = 3; + await flush(); + // The three writes are flushed together → a single re-run. + expect(runs).toBe(2); + expect(count.value).toBe(3); + }); + + it("2.3 - does not re-run on equal-value writes", async function () { + const count = signal(1); + let runs = 0; + effect(() => { + runs++; + count.value; + }); + await flush(); + expect(runs).toBe(1); + + count.value = 1; // same value (Object.is) → no notification + await flush(); + expect(runs).toBe(1); + }); + + it("2.4 - dispose() stops the effect", async function () { + const count = signal(1); + let runs = 0; + const dispose = effect(() => { + runs++; + count.value; + }); + await flush(); + expect(runs).toBe(1); + + dispose(); + count.value = 2; + await flush(); + expect(runs).toBe(1); + }); + + it("2.5 - re-tracks dependencies on each run", async function () { + const toggle = signal(true); + const a = signal("a"); + const b = signal("b"); + const seen = []; + effect(() => seen.push(toggle.value ? a.value : b.value)); + expect(seen).toEqual(["a"]); + + // While toggle is true, b is not a dependency. + b.value = "b2"; + await flush(); + expect(seen).toEqual(["a"]); + + toggle.value = false; + await flush(); + expect(seen).toEqual(["a", "b2"]); + + // Now a is no longer a dependency. + a.value = "a2"; + await flush(); + expect(seen).toEqual(["a", "b2"]); + }); + }); + + describe("3 - computed", function () { + it("is lazy and returns current values synchronously, including peek", async function () { + const source = signal(1); + const derive = jest.fn(() => source.value * 2); + const value = computed(derive); + expect(derive).not.toHaveBeenCalled(); + source.value = 2; + await flush(); + expect(derive).not.toHaveBeenCalled(); + expect(value.value).toBe(4); + expect(value.value).toBe(4); + expect(derive).toHaveBeenCalledTimes(1); + batch(() => { + source.value = 3; + expect(value.peek()).toBe(6); + }); + expect(derive).toHaveBeenCalledTimes(2); + }); + + it("does not subscribe an effect through computed peek", async function () { + const source = signal(1); + const value = computed(() => source.value * 2); + const read = jest.fn(() => value.peek()); + const dispose = effect(read); + source.value = 2; + await flush(); + expect(read).toHaveBeenCalledTimes(1); + expect(value.peek()).toBe(4); + dispose(); + }); + + it("skips effects when the computed result is unchanged", async function () { + const source = signal(1); + const parity = computed(() => source.value % 2); + const read = jest.fn(() => parity.value); + const dispose = effect(read); + source.value = 3; + await flush(); + expect(read).toHaveBeenCalledTimes(1); + source.value = 4; + await flush(); + expect(read).toHaveBeenCalledTimes(2); + dispose(); + }); + + it("evaluates overlapping dependency paths once with consistent values", async function () { + const source = signal(1); + const doubled = computed(() => source.value * 2); + const derive = jest.fn(() => source.value + doubled.value); + const sum = computed(derive); + const seen = []; + const dispose = effect(() => seen.push(sum.value)); + for (const value of [2, 3]) { + source.value = value; + await flush(); + } + expect(seen).toEqual([3, 6, 9]); + expect(derive).toHaveBeenCalledTimes(3); + dispose(); + }); + + it("caches computation errors and recovers after a dependency changes", function () { + const source = signal(0); + const derive = jest.fn(() => { + if (!source.value) throw new Error("missing value"); + return source.value; + }); + const value = computed(derive); + expect(() => value.value).toThrow("missing value"); + expect(() => value.peek()).toThrow("missing value"); + expect(derive).toHaveBeenCalledTimes(1); + source.value = 1; + expect(value.value).toBe(1); + }); + + it("freezes undefined when disposed before its first read", function () { + const derive = jest.fn(() => 42); + const value = computed(derive); + value.dispose(); + value.dispose(); + expect(value.value).toBeUndefined(); + expect(derive).not.toHaveBeenCalled(); + }); + + it("settles a computed chain before notifying consumers once", async function () { + const a = signal(1); + const b = computed(() => a.value * 2); + const c = computed(() => b.value * 2); + const seen = []; + const dispose = effect(() => seen.push([a.value, c.value])); + + a.value = 2; + await flush(); + expect(seen).toEqual([ + [1, 4], + [2, 8], + ]); + + a.value = 3; + await flush(); + expect(seen).toEqual([ + [1, 4], + [2, 8], + [3, 12], + ]); + dispose(); + b.dispose(); + c.dispose(); + }); + + it("settles derivations written by an effect before remaining consumers", async function () { + const trigger = signal(0); + const a = signal(1); + const b = computed(() => a.value * 2); + const write = effect(() => { + if (trigger.value) a.value = 2; + }); + const seen = []; + const read = effect(() => seen.push([trigger.value, a.value, b.value])); + + trigger.value = 1; + await flush(); + expect(seen).toEqual([ + [0, 1, 2], + [1, 2, 4], + ]); + write(); + read(); + b.dispose(); + }); + + it("does not run disposed computations that are pending", async function () { + const a = signal(1); + const derive = jest.fn(() => a.value * 2); + const b = computed(derive); + expect(b.value).toBe(2); + a.value = 2; + b.dispose(); + await flush(); + expect(derive).toHaveBeenCalledTimes(1); + expect(b.value).toBe(2); + }); + + it("3.1 - derives from other signals", async function () { + const first = signal("Jane"); + const last = signal("Doe"); + const full = computed(() => `${first.value} ${last.value}`); + expect(full.value).toBe("Jane Doe"); + + first.value = "John"; + await flush(); + expect(full.value).toBe("John Doe"); + }); + + it("3.2 - is reactive as an effect dependency", async function () { + const count = signal(2); + const doubled = computed(() => count.value * 2); + const seen = []; + effect(() => seen.push(doubled.value)); + expect(seen).toEqual([4]); + + count.value = 5; + await flush(); + expect(seen).toEqual([4, 10]); + }); + }); + + describe("4 - batch", function () { + it("4.1 - defers flushing until the batch ends", async function () { + const a = signal(1); + const b = signal(2); + let runs = 0; + effect(() => { + runs++; + a.value + b.value; + }); + expect(runs).toBe(1); + + batch(() => { + a.value = 10; + b.value = 20; + }); + await flush(); + expect(runs).toBe(2); + }); + }); + + describe("5 - untracked", function () { + it("5.1 - reads without creating a dependency", async function () { + const tracked = signal(1); + const hidden = signal(1); + let runs = 0; + effect(() => { + runs++; + tracked.value; + untracked(() => hidden.value); + }); + await flush(); + expect(runs).toBe(1); + + hidden.value = 2; + await flush(); + expect(runs).toBe(1); // not a dependency + + tracked.value = 2; + await flush(); + expect(runs).toBe(2); + }); + }); +}); From 3ffce050f88e8ae1605cfd7f8d07d962a497bf8c Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 3 Jun 2026 23:13:02 +0200 Subject: [PATCH 2/3] feat(pat-bind): Add new pattern for data binding. --- index.html | 6 + src/pat/bind/bind.js | 371 ++++++++++++++++++++++++++++++++++ src/pat/bind/bind.test.js | 276 +++++++++++++++++++++++++ src/pat/bind/documentation.md | 102 ++++++++++ src/pat/bind/index.html | 183 +++++++++++++++++ src/patterns.js | 1 + 6 files changed, 939 insertions(+) create mode 100644 src/pat/bind/bind.js create mode 100644 src/pat/bind/bind.test.js create mode 100644 src/pat/bind/documentation.md create mode 100644 src/pat/bind/index.html diff --git a/index.html b/index.html index 12fe5f6a7..80ece4eeb 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,12 @@

Behavioural

title="Inject history" >Inject historyunknown + Bindβ ``) and inserted later. +const radio_listener_refs = new WeakMap(); // document -> ref count + +function radio_change_listener(event) { + if (event.target?.matches?.('input[type="radio"]')) { + sync_radio_group(event.target); + } +} + +function add_radio_listener(doc) { + const count = radio_listener_refs.get(doc) || 0; + if (count === 0) { + doc.addEventListener("change", radio_change_listener, { capture: true }); + } + radio_listener_refs.set(doc, count + 1); +} + +function remove_radio_listener(doc) { + const count = (radio_listener_refs.get(doc) || 1) - 1; + if (count <= 0) { + radio_listener_refs.delete(doc); + doc.removeEventListener("change", radio_change_listener, { capture: true }); + } else { + radio_listener_refs.set(doc, count); + } +} + +/** + * Resolve the scope root for an element: the nearest ancestor (or itself) + * marked with ``data-pat-bind-scope``, falling back to ``document``. + */ +function get_scope_root(el) { + return el.closest("[data-pat-bind-scope]") || document; +} + +/** + * Get - lazily creating - the signal backing a channel within an element's + * scope. Created on first use, so source/target order in the DOM is irrelevant. + */ +function get_channel(el, key) { + const root = get_scope_root(el); + let channels = channel_registry.get(root); + if (!channels) { + channels = new Map(); + channel_registry.set(root, channels); + } + let channel = channels.get(key); + if (!channel) { + channel = { signal: signal(undefined), users: 0 }; + channels.set(key, channel); + } + channel.users++; + return { + signal: channel.signal, + release: () => { + if (--channel.users === 0) { + channels.delete(key); + } + }, + }; +} + +/** + * Turn an accessor string into a ``{ read, write, event, observe }`` descriptor. + * + * - ``read()`` reads the bound value off the element. + * - ``write(v)`` writes a value onto the element. + * - ``event`` native event to listen to for source updates (or ``null``). + * - ``observe`` MutationObserver target for source updates (or ``null``). + * + * @param {Element} el - The bound element. + * @param {string} accessor - The accessor string, possibly empty (inferred). + * @param {function} sanitize - HTML sanitizer used for the ``::html`` accessor. + */ +function resolve_accessor(el, accessor, sanitize) { + if (!accessor) { + // Infer the accessor from the element type. + if (dom.is_input(el)) { + const type = (el.getAttribute("type") || "").toLowerCase(); + accessor = type === "checkbox" || type === "radio" ? "[checked]" : "[value]"; + } else { + accessor = "::text"; + } + } + + if (accessor === "::text") { + return { + read: () => el.textContent, + write: (v) => { + el.textContent = v ?? ""; + }, + event: null, + observe: "content", + }; + } + + if (accessor === "::html") { + return { + read: () => el.innerHTML, + write: (v) => { + el.innerHTML = sanitize(v ?? ""); + }, + event: null, + observe: "content", + }; + } + + const attr_match = accessor.match(/^\[\s*([a-z][a-z0-9_-]*)\s*\]$/i); + if (attr_match) { + const attr = attr_match[1]; + // For live form-control state, ``[value]`` and ``[checked]`` map to the + // IDL property and the native ``input``/``change`` event, not to the + // static attribute. + const use_property = (attr === "value" || attr === "checked") && attr in el; + if (use_property) { + const radio = attr === "checked" && el.matches('input[type="radio"]'); + return { + read: () => el[attr], + write: (v) => { + el[attr] = attr === "checked" ? Boolean(v) : v ?? ""; + if (radio) { + sync_radio_group(el); + } + }, + radio, + event: attr === "checked" ? "change" : "input", + observe: null, + }; + } + return { + read: () => el.getAttribute(attr), + write: (v) => { + if (v === null || v === undefined || v === false) { + el.removeAttribute(attr); + } else { + el.setAttribute(attr, String(v)); + } + }, + event: null, + observe: `attribute:${attr}`, + }; + } + + log.error(`Invalid bind accessor: "${accessor}".`, el); + return null; +} + +/** + * The MutationObserver config for a source accessor that has no native event. + */ +function mutation_config(observe) { + if (observe === "content") { + return { childList: true, characterData: true, subtree: true }; + } + if (observe.startsWith("attribute:")) { + return { + attributes: true, + attributeFilter: [observe.slice("attribute:".length)], + }; + } + return {}; +} + +/** + * Default binding direction when not given explicitly: form controls bound to + * their value/checked default to two-way, everything else to ``target``. + */ +function infer_direction(el, accessor) { + if (dom.is_input(el) && accessor.event) { + return "both"; + } + return "target"; +} + +class Pattern extends BasePattern { + static name = "bind"; + static trigger = ".pat-bind"; + static parser = parser; + + // One binding per ``&&``-separated config; ``this.options`` becomes an array. + parser_multiple = true; + // Each element defines its own bindings; do not inherit from ancestors. + parser_inherit = false; + + async init() { + this._disposers = []; // effect dispose functions + this._observers = []; // MutationObservers + this._listeners = []; // event targets and listener ids, for cleanup + this._channel_releases = []; + + const bindings = Array.isArray(this.options) ? this.options : [this.options]; + + // Lazily load DOMPurify only when an ``::html`` accessor is in use. + let sanitize = (v) => v; + if (bindings.some((binding) => binding.value === "::html")) { + const DOMPurify = (await import("dompurify")).default; + sanitize = (v) => DOMPurify.sanitize(v ?? ""); + } + + for (const binding of bindings) { + this.setup_binding(binding, sanitize); + } + } + + setup_binding(binding, sanitize) { + const key = binding.key; + if (!key) { + log.warn("Ignoring a pat-bind binding without a `key`.", this.el); + return; + } + + const accessor = resolve_accessor(this.el, binding.value, sanitize); + if (!accessor) { + return; + } + + const direction = binding.direction || infer_direction(this.el, accessor); + const channel = get_channel(this.el, key); + const sig = channel.signal; + this._channel_releases.push(channel.release); + + if (direction === "target" || direction === "both") { + this.setup_target(sig, accessor); + } + if (direction === "source" || direction === "both") { + this.setup_source(sig, accessor, key); + } + } + + // Channel → element: keep the element's accessor in sync with the signal. + setup_target(sig, accessor) { + const dispose = effect(() => { + const value = sig.value; + if (value === undefined) { + // Channel not seeded yet; leave the server-rendered DOM as-is. + return; + } + if (Object.is(accessor.read(), value)) { + // Already in sync. Avoids redundant writes (e.g. resetting the + // caret of an input) and breaks two-way feedback loops. + return; + } + accessor.write(value); + }); + this._disposers.push(dispose); + } + + // Element → channel: push the element's accessor value into the signal. + setup_source(sig, accessor, key) { + // Seed the channel from the element's current value. + sig.value = accessor.read(); + + // The signal's own equal-value guard stops the + // signal → write → observer → read → signal feedback loop. + const handler = () => { + sig.value = accessor.read(); + }; + + if (accessor.radio) { + let sources = radio_sources.get(this.el); + if (!sources) { + sources = new Set(); + radio_sources.set(this.el, sources); + } + sources.add(handler); + const doc = this.el.ownerDocument; + add_radio_listener(doc); + this._disposers.push(() => { + sources.delete(handler); + remove_radio_listener(doc); + }); + } else if (accessor.event) { + const id = `pat-bind--${key}--${this.uuid}`; + events.add_event_listener(this.el, accessor.event, id, handler, {}); + this._listeners.push([this.el, id]); + } else if (accessor.observe) { + const observer = new MutationObserver(handler); + observer.observe(this.el, mutation_config(accessor.observe)); + this._observers.push(observer); + } + } + + destroy() { + for (const dispose of this._disposers) { + dispose(); + } + for (const observer of this._observers) { + observer.disconnect(); + } + for (const [target, id] of this._listeners) { + events.remove_event_listener(target, id); + } + for (const release of this._channel_releases.splice(0)) { + release(); + } + super.destroy(); + } +} + +registry.register(Pattern); + +export default Pattern; diff --git a/src/pat/bind/bind.test.js b/src/pat/bind/bind.test.js new file mode 100644 index 000000000..3021b6b08 --- /dev/null +++ b/src/pat/bind/bind.test.js @@ -0,0 +1,276 @@ +import Pattern from "./bind"; +import events from "../../core/events"; +import utils from "../../core/utils"; + +// MutationObserver callbacks and effects both run as microtasks; give them a +// couple of ticks to settle. +const tick = () => utils.timeout(1); + +async function init_bindings() { + for (const el of document.querySelectorAll(".pat-bind")) { + if (!el["pattern-bind"]) { + await events.await_pattern_init(new Pattern(el)); + } + } + await tick(); +} + +function destroy_bindings() { + for (const el of document.querySelectorAll("*")) { + el["pattern-bind"]?.destroy(); + } +} + +describe("pat-bind", function () { + afterEach(function () { + destroy_bindings(); + document.body.innerHTML = ""; + }); + + describe("1 - source → target", function () { + it("1.1 - updates a text target when the source select changes", async function () { + document.body.innerHTML = ` + + + `; + const select = document.querySelector("select"); + const span = document.querySelector("span"); + + const source = new Pattern(select); + const target = new Pattern(span); + await events.await_pattern_init(source); + await events.await_pattern_init(target); + + // The target is seeded from the source's initial value. + await tick(); + expect(span.textContent).toBe("red"); + + select.value = "green"; + select.dispatchEvent(events.change_event()); + select.dispatchEvent(events.input_event()); + await tick(); + + expect(span.textContent).toBe("green"); + }); + + it("1.2 - binds to an attribute target", async function () { + document.body.innerHTML = ` + + link + `; + const input = document.querySelector("input"); + const anchor = document.querySelector("a"); + + const source = new Pattern(input); + const target = new Pattern(anchor); + await events.await_pattern_init(source); + await events.await_pattern_init(target); + await tick(); + + expect(anchor.getAttribute("href")).toBe("/profile"); + + input.value = "/dashboard"; + input.dispatchEvent(events.input_event()); + await tick(); + + expect(anchor.getAttribute("href")).toBe("/dashboard"); + }); + }); + + describe("2 - two-way binding", function () { + it.each([true, false])( + "updates an unchecked radio when its peer is bound: %s", + async function (bound) { + document.body.innerHTML = ` +
+ + + +
+
+ + +
`; + await init_bindings(); + + document.querySelector("#b").click(); + await tick(); + expect(document.querySelector("#a").checked).toBe(false); + expect(document.querySelector("span").textContent).toBe("false"); + expect(document.querySelector("#other").checked).toBe(true); + expect(document.querySelector("#other-output").textContent).toBe("true"); + + document.querySelector("#a").click(); + await tick(); + expect(document.querySelector("span").textContent).toBe("true"); + } + ); + + it("updates radio peers when a channel checks a radio", async function () { + document.body.innerHTML = ` + + + + `; + await init_bindings(); + document.querySelector("#control").click(); + await tick(); + expect(document.querySelector("#b").checked).toBe(true); + expect(document.querySelector("#a").checked).toBe(false); + expect(document.querySelector("span").textContent).toBe("false"); + }); + + it("2.1 - keeps two inputs on the same channel in sync", async function () { + document.body.innerHTML = ` + + + `; + const a = document.querySelector("#a"); + const b = document.querySelector("#b"); + + const pa = new Pattern(a); + const pb = new Pattern(b); + await events.await_pattern_init(pa); + await events.await_pattern_init(pb); + await tick(); + + // b is seeded from the channel (which a seeded with "start"). + expect(b.value).toBe("start"); + + // Typing into b propagates back to a (two-way). + b.value = "changed"; + b.dispatchEvent(events.input_event()); + await tick(); + + expect(a.value).toBe("changed"); + }); + }); + + describe("3 - multiple bindings per element", function () { + it("3.1 - binds text and an attribute on one element", async function () { + document.body.innerHTML = ` + + +
+ `; + const out = document.querySelector("#out"); + + for (const el of document.querySelectorAll(".pat-bind")) { + const instance = new Pattern(el); + await events.await_pattern_init(instance); + } + await tick(); + + expect(out.textContent).toBe("Hello"); + expect(out.getAttribute("class")).toContain("active"); + }); + }); + + describe("4 - scope", function () { + it("4.1 - isolates channels per scope root", async function () { + document.body.innerHTML = ` +
+ + +
+
+ + +
+ `; + for (const el of document.querySelectorAll(".pat-bind")) { + const instance = new Pattern(el); + await events.await_pattern_init(instance); + } + await tick(); + + const spans = document.querySelectorAll("span"); + // Each scope keeps its own "v" channel. + expect(spans[0].textContent).toBe("one"); + expect(spans[1].textContent).toBe("two"); + }); + }); + + describe("5 - direction", function () { + it("5.1 - a target does not write back to the channel", async function () { + document.body.innerHTML = ` + + + `; + const [source, target] = document.querySelectorAll("input"); + + for (const el of document.querySelectorAll(".pat-bind")) { + const instance = new Pattern(el); + await events.await_pattern_init(instance); + } + await tick(); + + expect(target.value).toBe("from-source"); + + // Changing the target must NOT propagate back to the source. + target.value = "edited"; + target.dispatchEvent(events.input_event()); + await tick(); + + expect(source.value).toBe("from-source"); + }); + }); + + describe("6 - channel lifecycle", function () { + it.each(["", "data-pat-bind-scope"])( + "releases unused channels within a %s root", + async function (scope) { + document.body.innerHTML = `
+ + +
`; + await init_bindings(); + destroy_bindings(); + + document.querySelector("div").innerHTML = ` + placeholder + `; + await init_bindings(); + expect(document.querySelector("input").value).toBe("new"); + expect(document.querySelector("span").textContent).toBe("new"); + } + ); + + it("preserves a channel while another binding still uses it", async function () { + document.body.innerHTML = ` + + `; + await init_bindings(); + const input = document.querySelector("input"); + const instance = input["pattern-bind"]; + instance.destroy(); + instance.destroy(); // Repeated cleanup must not release another user's channel. + input.remove(); + document.body.insertAdjacentHTML( + "beforeend", + '' + ); + await init_bindings(); + expect(document.querySelector("input").value).toBe("existing"); + }); + + it("removes the radio group listener on destroy", async function () { + document.body.innerHTML = ` + + + `; + await init_bindings(); + document.querySelector("#a")["pattern-bind"].destroy(); + document.querySelector("#b").click(); + await tick(); + expect(document.querySelector("span").textContent).toBe("true"); + }); + }); +}); diff --git a/src/pat/bind/documentation.md b/src/pat/bind/documentation.md new file mode 100644 index 000000000..5123bb1a6 --- /dev/null +++ b/src/pat/bind/documentation.md @@ -0,0 +1,102 @@ +## Description + +The _bind_ pattern provides declarative data binding between DOM elements. +Select a value in one place and have a label, attribute or another field update +elsewhere - without writing any JavaScript. + +## Documentation + +Binding works through named _channels_. Every `pat-bind` element ties one of its +_accessors_ (a form value, an attribute, its text or its HTML) to a channel. +Elements sharing a channel stay in sync. + +A minimal example - a select drives a label: + + + + + +When the select changes, the span's text follows. + +### Configuration options + +Each binding is configured through `data-pat-bind` with the following keys: + +- `key` (**required**): the channel name. Elements with the same `key` (within + the same scope, see below) are bound together. +- `value`: the _accessor_ - which part of this element is bound. One of: + - `[]`: an attribute value, e.g. `[value]`, `[href]`, + `[class]`, `[aria-pressed]`. For form controls, `[value]` and + `[checked]` bind to the live form state (and react to `input` / + `change`), not to the static HTML attribute. + - `::text`: the element's text content (`textContent`). + - `::html`: the element's HTML content (`innerHTML`). Values are sanitized + with DOMPurify on write. + - _omitted_: inferred from the element. Form controls bind to their value + (or `checked` for checkboxes and radios); everything else binds to + `::text`. +- `direction`: the data-flow direction. One of: + - `source`: the element writes into the channel (it is an input). + - `target`: the channel writes into the element (it is a display). + - `both`: two-way binding. + - _omitted_: inferred. Form controls bound to their value/checked default + to `both`; everything else defaults to `target`. + +### Multiple bindings on one element + +Separate several bindings with `&&`, just like _pat-inject_: + +
+ +This element shows the `user_name` channel as its text and mirrors the `theme` +channel onto its `class` attribute. + +### Two-way binding + +Form controls are two-way by default, so two fields on the same channel mirror +each other: + + + + +Typing in either input updates the other. + +To bind a non-form element two-way - for example a `contenteditable` region - +request it explicitly: + +
+ +### Scope + +By default all channels live in a single, document-wide namespace. To reuse the +same channel name in independent regions - the typical case being repeated +content such as a list of cards - mark a container with `data-pat-bind-scope`. +Channel lookups resolve to the nearest scope ancestor, falling back to the +document. + +
+ + +
+ + +### Notes + +- Channel values are strings, except `[checked]` which is a boolean. +- Targets are only written once a channel has a value, so server-rendered + content is left untouched until a source provides data. +- Two-way binding on `::text` / `::html` relies on a `MutationObserver` and is + intended for editable elements; for plain displays prefer a `target` + binding. + +### Relation to other patterns + +_pat-bind_ is built on the reactive primitives in [core/signals](../../core/signals.md) (Patternslib +helpers backed by the official TC39 Signals polyfill). The signals are an implementation detail - the page author only ever +writes markup. diff --git a/src/pat/bind/index.html b/src/pat/bind/index.html new file mode 100644 index 000000000..42715c61f --- /dev/null +++ b/src/pat/bind/index.html @@ -0,0 +1,183 @@ + + + + pat-bind + + + + + +

pat-bind

+

+ Declarative data binding between DOM elements. Elements sharing a + channel (the key) stay in sync — no JavaScript + required. +

+ +
+

A source drives a target

+

Pick a colour; the label and the swatch follow.

+
+
+
+ +

+ You picked: + +

+

+ … and it is mirrored onto a + title attribute: + hover me +

+
+
+
+
+ +
+

Two-way binding

+

+ Form controls on the same channel mirror each other. Type in + either field. +

+
+
+
+ + +

+ Hello, + + ! +

+
+
+
+
+ +
+

Binding an attribute

+

A checkbox drives the disabled state of a button.

+
+
+
+ + +
+
+
+
+ +
+

Editable HTML (::html)

+

+ Edit the region below; its sanitized HTML is mirrored into the + preview. The ::html accessor sanitizes with + DOMPurify on write. +

+
+
+ Try bold or italic text… +
+

Preview:

+
+
+
+
+ +
+

Scope

+

+ Each card is its own scope (data-pat-bind-scope), so + the reused price channel stays local to the card. +

+
+
+

Card A

+ +

+ € +

+
+
+

Card B

+ +

+ € +

+
+
+
+ + diff --git a/src/patterns.js b/src/patterns.js index 7537e47df..aafde2c1a 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -12,6 +12,7 @@ import "./pat/auto-scale/auto-scale"; import "./pat/auto-submit/auto-submit"; import "./pat/auto-suggest/auto-suggest"; import "./pat/autofocus/autofocus"; +import "./pat/bind/bind"; import "./pat/breadcrumbs/breadcrumbs"; import "./pat/bumper/bumper"; import "./pat/calendar/calendar"; From 0379ca8ed8053c5108150a228b08c721cf2b5d68 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sat, 26 Sep 2026 16:04:43 +0200 Subject: [PATCH 3/3] pnpm install. --- pnpm-lock.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e9e4ba8f..455a79c41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: showdown-prettify: specifier: ^1.3.0 version: 1.3.0 + signal-polyfill: + specifier: ^0.2.2 + version: 0.2.2 slick-carousel: specifier: git+https://github.com/kenwheeler/slick.git#d0716f19aa730006ee80ab026625fb1107816a97 version: https://codeload.github.com/kenwheeler/slick/tar.gz/d0716f19aa730006ee80ab026625fb1107816a97(jquery@3.7.1) @@ -4902,6 +4905,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + signal-polyfill@0.2.2: + resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -10750,6 +10756,8 @@ snapshots: signal-exit@4.1.0: {} + signal-polyfill@0.2.2: {} + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29