From e15b5c9dbcdea2b1b9de9afdade9ff0e9db7265b Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 8 Mar 2026 11:15:20 +0000 Subject: [PATCH] Add event bindings and data-sx-emit processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adapter-dom.sx: detect :on-click/:on-submit etc. in render-dom-element — if attr starts with "on-" and value is callable, wire via dom-listen - orchestration.sx: add process-emit-elements for data-sx-emit attrs — auto-dispatch custom events on click with optional JSON detail - bootstrap_js.py: add processEmitElements RENAME - Regenerate sx-ref.js with all changes - Update reactive-islands status table Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-ref.js | 23 ++++++++++++++++--- shared/sx/ref/adapter-dom.sx | 11 ++++++++++ shared/sx/ref/bootstrap_js.py | 1 + shared/sx/ref/orchestration.sx | 39 +++++++++++++++++++++++++++++++-- sx/sx/reactive-islands.sx | 8 +++++-- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 85ca7b8..09bb94d 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-08T11:12:31Z"; + var SX_VERSION = "2026-03-08T11:14:47Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1490,7 +1490,10 @@ continue; } else { return NIL; } } }; return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { var attrName = keywordName(arg); var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); - (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))); + (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? (function() { + var eventName = substring(attrName, 3, stringLength(attrName)); + return domListen(el, eventName, attrVal); +})() : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal))))))); return assoc(state, "skip", true, "i", (get(state, "i") + 1)); })() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1))))); })(); }, {["i"]: 0, ["skip"]: false}, args); @@ -2496,7 +2499,8 @@ return postSwap(target); })) : NIL); })(); processBoosted(root); processSse(root); -return bindInlineHandlers(root); }; +bindInlineHandlers(root); +return processEmitElements(root); }; // process-one var processOne = function(el) { return (function() { @@ -2504,6 +2508,19 @@ return bindInlineHandlers(root); }; return (isSxTruthy(verbInfo) ? (isSxTruthy(!isSxTruthy(domHasAttr(el, "sx-disable"))) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL) : NIL); })(); }; + // process-emit-elements + var processEmitElements = function(root) { return (function() { + var els = domQueryAll(sxOr(root, domBody()), "[data-sx-emit]"); + return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "emit"))) ? (markProcessed(el, "emit"), (function() { + var eventName = domGetAttr(el, "data-sx-emit"); + return (isSxTruthy(eventName) ? domListen(el, "click", function(e) { return (function() { + var detailJson = domGetAttr(el, "data-sx-emit-detail"); + var detail = (isSxTruthy(detailJson) ? jsonParse(detailJson) : {}); + return domDispatch(el, eventName, detail); +})(); }) : NIL); +})()) : NIL); }, els); +})(); }; + // handle-popstate var handlePopstate = function(scrollY) { return (function() { var url = browserLocationHref(); diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 15b0e00..339845e 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -170,6 +170,12 @@ ;; nil or false → skip (or (nil? attr-val) (= attr-val false)) nil + ;; Event handler: on-click, on-submit, on-input, etc. + ;; Value must be callable (lambda/function) + (and (starts-with? attr-name "on-") + (callable? attr-val)) + (let ((event-name (substring attr-name 3 (string-length attr-name)))) + (dom-listen el event-name attr-val)) ;; Boolean attr (contains? BOOLEAN_ATTRS attr-name) (when attr-val (dom-set-attr el attr-name "")) @@ -589,6 +595,11 @@ ;; (dom-child-nodes frag) → list of child nodes ;; (dom-remove-children-after m)→ void (remove all siblings after marker) ;; (dom-set-data el key val) → void (store arbitrary data on element) +;; (dom-get-data el key) → any (retrieve data stored on element) +;; +;; Event handling: +;; (dom-listen el name handler) → remove-fn (addEventListener, returns remover) +;; (dom-dispatch el name detail)→ boolean (dispatch CustomEvent, bubbles: true) ;; ;; Content parsing: ;; (dom-parse-html s) → DocumentFragment from HTML string diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 52ea517..d2f8d59 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -397,6 +397,7 @@ class JSEmitter: "bind-sse": "bindSse", "bind-sse-swap": "bindSseSwap", "bind-inline-handlers": "bindInlineHandlers", + "process-emit-elements": "processEmitElements", "bind-preload-for": "bindPreloadFor", "do-preload": "doPreload", "VERB_SELECTOR": "VERB_SELECTOR", diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index fbbed8f..970b571 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -1087,10 +1087,11 @@ (mark-processed! el "verb") (process-one el))) els)) - ;; Also process boost, SSE, inline handlers + ;; Also process boost, SSE, inline handlers, emit attributes (process-boosted root) (process-sse root) - (bind-inline-handlers root))) + (bind-inline-handlers root) + (process-emit-elements root))) (define process-one @@ -1104,6 +1105,40 @@ (bind-preload-for el)))))) +;; -------------------------------------------------------------------------- +;; data-sx-emit — auto-dispatch custom events for lake→island bridge +;; -------------------------------------------------------------------------- +;; +;; Elements with data-sx-emit="event-name" get a click listener that +;; dispatches a CustomEvent with that name. Optional data-sx-emit-detail +;; provides JSON payload. +;; +;; Example: +;; +;; +;; On click → dispatches CustomEvent "cart:add" with detail {id:42, name:"Widget"} +;; The event bubbles up to the island container where bridge-event catches it. + +(define process-emit-elements + (fn (root) + (let ((els (dom-query-all (or root (dom-body)) "[data-sx-emit]"))) + (for-each + (fn (el) + (when (not (is-processed? el "emit")) + (mark-processed! el "emit") + (let ((event-name (dom-get-attr el "data-sx-emit"))) + (when event-name + (dom-listen el "click" + (fn (e) + (let ((detail-json (dom-get-attr el "data-sx-emit-detail")) + (detail (if detail-json (json-parse detail-json) (dict)))) + (dom-dispatch el event-name detail)))))))) + els)))) + + ;; -------------------------------------------------------------------------- ;; History: popstate handler ;; -------------------------------------------------------------------------- diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index f4974e5..7d6ac16 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -115,8 +115,12 @@ (td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx: sx-hydrate-islands, hydrate-island, dispose-island")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Event bindings") - (td :class "px-3 py-2 text-amber-600 font-medium" "TODO") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" ":on-click wiring")) + (td :class "px-3 py-2 text-green-700 font-medium" "Spec'd") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :on-click → domListen")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "data-sx-emit processing") + (td :class "px-3 py-2 text-green-700 font-medium" "Spec'd") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "orchestration.sx: process-emit-elements")) (tr (td :class "px-3 py-2 text-stone-700" "Keyed list reconciliation") (td :class "px-3 py-2 text-amber-600 font-medium" "TODO")