From 6bda2bafa2d4807f563139f4770d6a254c4d6266 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 8 Mar 2026 16:27:55 +0000 Subject: [PATCH] Add Phase 2 P1 features: reactive class/style, refs, portals - :class-map dict toggles classes reactively via classList.add/remove - :style-map dict sets inline styles reactively via el.style[prop] - ref/ref-get/ref-set! mutable boxes (non-reactive, like useRef) - :ref attribute sets ref.current to DOM element after rendering - portal render-dom form renders children into remote target element - Portal content auto-removed on island disposal via register-in-scope - Added #portal-root div to page shell template - Added stop-propagation and dom-focus platform functions - Demo islands for all three features on the demo page - Updated status tables: all P0/P1 features marked Done Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 47 ++++++++- shared/static/scripts/sx-ref.js | 47 ++++++++- shared/sx/helpers.py | 1 + shared/sx/ref/adapter-dom.sx | 115 ++++++++++++++++++++- shared/sx/ref/bootstrap_js.py | 4 + shared/sx/ref/signals.sx | 23 ++++- sx/sx/reactive-islands.sx | 149 +++++++++++++++++++++++++--- 7 files changed, 357 insertions(+), 29 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 4dc5628..b4e8411 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-08T16:12:12Z"; + var SX_VERSION = "2026-03-08T16:24:34Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1492,7 +1492,7 @@ return result; }, args); 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((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (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))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (isSxTruthy((attrName == "class-map")) ? reactiveClassMap(el, attrVal) : (isSxTruthy((attrName == "style-map")) ? reactiveStyleMap(el, attrVal) : (isSxTruthy((attrName == "ref")) ? refSet_b(attrVal, el) : (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); @@ -1546,7 +1546,7 @@ return result; }, args); var renderDomUnknownComponent = function(name) { return error((String("Unknown component: ") + String(name))); }; // RENDER_DOM_FORMS - var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each"]; + var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal"]; // render-dom-form? var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; @@ -1604,7 +1604,7 @@ return result; }, args); return domAppend(frag, val); })(); }, coll); return frag; -})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "for-each")) ? (function() { +})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(args, env, ns) : (isSxTruthy((name == "for-each")) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); var frag = createFragment(); @@ -1613,7 +1613,7 @@ return result; }, args); return domAppend(frag, val); })(); } } return frag; -})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))); }; +})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))))); }; // render-lambda-dom var renderLambdaDom = function(f, args, env, ns) { return (function() { @@ -1755,6 +1755,32 @@ return (isSxTruthy(testFn()) ? (function() { return domListen(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); }); })(); }; + // reactive-class-map + var reactiveClassMap = function(el, classDict) { return effect(function() { return forEach(function(cls) { return (function() { + var val = deref(get(classDict, cls)); + return (isSxTruthy(val) ? domAddClass(el, cls) : domRemoveClass(el, cls)); +})(); }, keys(classDict)); }); }; + + // reactive-style-map + var reactiveStyleMap = function(el, styleDict) { return effect(function() { return forEach(function(prop) { return domSetStyle(el, prop, (String(deref(get(styleDict, prop))))); }, keys(styleDict)); }); }; + + // render-dom-portal + var renderDomPortal = function(args, env, ns) { return (function() { + var selector = trampoline(evalExpr(first(args), env)); + var target = domQuery(selector); + return (isSxTruthy(!isSxTruthy(target)) ? (logWarn((String("Portal target not found: ") + String(selector))), createComment((String("portal: ") + String(selector) + String(" (not found)")))) : (function() { + var marker = createComment((String("portal: ") + String(selector))); + var frag = createFragment(); + { var _c = rest(args); for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } } + (function() { + var portalNodes = domChildNodes(frag); + domAppend(target, frag); + return registerInScope(function() { return forEach(function(n) { return domRemove(n); }, portalNodes); }); +})(); + return marker; +})()); +})(); }; + // === Transpiled from engine === @@ -3014,6 +3040,15 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // register-in-scope var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; + // ref + var ref = function(initial) { return {["current"]: initial}; }; + + // ref-get + var refGet = function(r) { return get(r, "current"); }; + + // ref-set! + var refSet_b = function(r, v) { return dictSet(r, "current", v); }; + // *store-registry* var _storeRegistry = {}; @@ -3775,6 +3810,8 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // --- Events --- function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); } + function domFocus(el) { if (el && el.focus) el.focus(); } function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } function domAddListener(el, event, fn, opts) { diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 4dc5628..b4e8411 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-08T16:12:12Z"; + var SX_VERSION = "2026-03-08T16:24:34Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1492,7 +1492,7 @@ return result; }, args); 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((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (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))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (isSxTruthy((attrName == "class-map")) ? reactiveClassMap(el, attrVal) : (isSxTruthy((attrName == "style-map")) ? reactiveStyleMap(el, attrVal) : (isSxTruthy((attrName == "ref")) ? refSet_b(attrVal, el) : (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); @@ -1546,7 +1546,7 @@ return result; }, args); var renderDomUnknownComponent = function(name) { return error((String("Unknown component: ") + String(name))); }; // RENDER_DOM_FORMS - var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each"]; + var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal"]; // render-dom-form? var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; @@ -1604,7 +1604,7 @@ return result; }, args); return domAppend(frag, val); })(); }, coll); return frag; -})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "for-each")) ? (function() { +})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(args, env, ns) : (isSxTruthy((name == "for-each")) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); var frag = createFragment(); @@ -1613,7 +1613,7 @@ return result; }, args); return domAppend(frag, val); })(); } } return frag; -})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))); }; +})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))))); }; // render-lambda-dom var renderLambdaDom = function(f, args, env, ns) { return (function() { @@ -1755,6 +1755,32 @@ return (isSxTruthy(testFn()) ? (function() { return domListen(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); }); })(); }; + // reactive-class-map + var reactiveClassMap = function(el, classDict) { return effect(function() { return forEach(function(cls) { return (function() { + var val = deref(get(classDict, cls)); + return (isSxTruthy(val) ? domAddClass(el, cls) : domRemoveClass(el, cls)); +})(); }, keys(classDict)); }); }; + + // reactive-style-map + var reactiveStyleMap = function(el, styleDict) { return effect(function() { return forEach(function(prop) { return domSetStyle(el, prop, (String(deref(get(styleDict, prop))))); }, keys(styleDict)); }); }; + + // render-dom-portal + var renderDomPortal = function(args, env, ns) { return (function() { + var selector = trampoline(evalExpr(first(args), env)); + var target = domQuery(selector); + return (isSxTruthy(!isSxTruthy(target)) ? (logWarn((String("Portal target not found: ") + String(selector))), createComment((String("portal: ") + String(selector) + String(" (not found)")))) : (function() { + var marker = createComment((String("portal: ") + String(selector))); + var frag = createFragment(); + { var _c = rest(args); for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } } + (function() { + var portalNodes = domChildNodes(frag); + domAppend(target, frag); + return registerInScope(function() { return forEach(function(n) { return domRemove(n); }, portalNodes); }); +})(); + return marker; +})()); +})(); }; + // === Transpiled from engine === @@ -3014,6 +3040,15 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // register-in-scope var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; + // ref + var ref = function(initial) { return {["current"]: initial}; }; + + // ref-get + var refGet = function(r) { return get(r, "current"); }; + + // ref-set! + var refSet_b = function(r, v) { return dictSet(r, "current", v); }; + // *store-registry* var _storeRegistry = {}; @@ -3775,6 +3810,8 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // --- Events --- function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); } + function domFocus(el) { if (el && el.focus) el.focus(); } function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } function domAddListener(el, event, fn, opts) { diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 813f5ad..f3b2044 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -662,6 +662,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details. +
""" diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 6fb66b5..08fc2d1 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -185,6 +185,15 @@ ;; Two-way input binding: :bind signal (and (= attr-name "bind") (signal? attr-val)) (bind-input el attr-val) + ;; class-map: reactively toggle classes + (= attr-name "class-map") + (reactive-class-map el attr-val) + ;; style-map: reactively set inline styles + (= attr-name "style-map") + (reactive-style-map el attr-val) + ;; ref: set ref.current to this element + (= attr-name "ref") + (ref-set! attr-val el) ;; Boolean attr (contains? BOOLEAN_ATTRS attr-name) (when attr-val (dom-set-attr el attr-name "")) @@ -306,7 +315,7 @@ (define RENDER_DOM_FORMS (list "if" "when" "cond" "case" "let" "let*" "begin" "do" "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" - "map" "map-indexed" "filter" "for-each")) + "map" "map-indexed" "filter" "for-each" "portal")) (define render-dom-form? (fn (name) @@ -423,6 +432,10 @@ (= name "filter") (render-to-dom (trampoline (eval-expr expr env)) env ns) + ;; portal — render children into a remote target element + (= name "portal") + (render-dom-portal args env ns) + ;; for-each (render variant) (= name "for-each") (let ((f (trampoline (eval-expr (nth expr 1) env))) @@ -700,6 +713,86 @@ (reset! sig (dom-get-prop el "value")))))))) +;; -------------------------------------------------------------------------- +;; reactive-class-map — toggle classes based on signals +;; -------------------------------------------------------------------------- +;; +;; Dict values should be signals or booleans. Signals are deref'd reactively. +;; +;; (div :class-map (dict "active" selected? "hidden" hide-computed)) +;; +;; Creates a single effect that deref's each value (tracking signal deps) +;; and calls classList.add/remove for each class. + +(define reactive-class-map + (fn (el class-dict) + (effect (fn () + (for-each + (fn (cls) + (let ((val (deref (get class-dict cls)))) + (if val + (dom-add-class el cls) + (dom-remove-class el cls)))) + (keys class-dict)))))) + + +;; -------------------------------------------------------------------------- +;; reactive-style-map — reactively set inline styles via signals +;; -------------------------------------------------------------------------- +;; +;; Dict values should be signals or strings. Signals are deref'd reactively. +;; +;; (div :style-map (dict "width" width-sig "opacity" opacity-computed)) +;; +;; Creates a single effect that deref's each value and sets the style property. + +(define reactive-style-map + (fn (el style-dict) + (effect (fn () + (for-each + (fn (prop) + (dom-set-style el prop (str (deref (get style-dict prop))))) + (keys style-dict)))))) + + +;; -------------------------------------------------------------------------- +;; render-dom-portal — render children into a remote target element +;; -------------------------------------------------------------------------- +;; +;; (portal "#modal-root" (div "content")) +;; +;; Renders children into the DOM node matched by the selector, rather than +;; into the current position. Returns a comment marker at the original +;; position. Registers a disposer to clean up portal content on island +;; teardown. + +(define render-dom-portal + (fn (args env ns) + (let ((selector (trampoline (eval-expr (first args) env))) + (target (dom-query selector))) + (if (not target) + ;; Target not found — render nothing, log warning + (do + (log-warn (str "Portal target not found: " selector)) + (create-comment (str "portal: " selector " (not found)"))) + (let ((marker (create-comment (str "portal: " selector))) + (frag (create-fragment))) + ;; Render children into the fragment + (for-each + (fn (child) (dom-append frag (render-to-dom child env ns))) + (rest args)) + ;; Track portal nodes for disposal + (let ((portal-nodes (dom-child-nodes frag))) + ;; Append into remote target + (dom-append target frag) + ;; Register disposer: remove portal content on island teardown + (register-in-scope + (fn () + (for-each (fn (n) (dom-remove n)) portal-nodes)))) + ;; Return marker at original position + marker))))) + + ;; -------------------------------------------------------------------------- ;; Platform interface — DOM adapter ;; -------------------------------------------------------------------------- @@ -724,6 +817,20 @@ ;; (dom-set-data el key val) → void (store arbitrary data on element) ;; (dom-get-data el key) → any (retrieve data stored on element) ;; +;; Property access (for input binding): +;; (dom-set-prop el name val) → void (set JS property: el[name] = val) +;; (dom-get-prop el name) → any (read JS property: el[name]) +;; +;; Class manipulation (for reactive class-map): +;; (dom-add-class el cls) → void (classList.add) +;; (dom-remove-class el cls) → void (classList.remove) +;; +;; Style manipulation (for reactive style-map): +;; (dom-set-style el prop val) → void (el.style[prop] = val) +;; +;; Query (for portals): +;; (dom-query selector) → Element or nil (document.querySelector) +;; ;; Event handling: ;; (dom-listen el name handler) → remove-fn (addEventListener, returns remover) ;; (dom-dispatch el name detail)→ boolean (dispatch CustomEvent, bubbles: true) @@ -748,7 +855,11 @@ ;; ;; From signals.sx: ;; signal, deref, reset!, swap!, computed, effect, batch -;; signal?, with-island-scope +;; signal?, with-island-scope, register-in-scope +;; ref, ref-get, ref-set! +;; +;; Pure primitives used: +;; keys, get, str ;; ;; Iteration: ;; (for-each-indexed fn coll) → call fn(index, item) for each element diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index eddeacf..1a0cc35 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -444,6 +444,8 @@ class JSEmitter: "dom-outer-html": "domOuterHtml", "dom-body-inner-html": "domBodyInnerHtml", "prevent-default": "preventDefault_", + "stop-propagation": "stopPropagation_", + "dom-focus": "domFocus", "element-value": "elementValue", "validate-for-request": "validateForRequest", "with-transition": "withTransition", @@ -3461,6 +3463,8 @@ PLATFORM_ORCHESTRATION_JS = """ // --- Events --- function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); } + function domFocus(el) { if (el && el.focus) el.focus(); } function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } function domAddListener(el, event, fn, opts) { diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index 9595f9e..07cfd40 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -306,7 +306,28 @@ ;; ========================================================================== -;; 12. Named stores — page-level signal containers (L3) +;; 12. Refs — mutable boxes, no reactivity +;; ========================================================================== +;; +;; A ref is a mutable container that does NOT trigger subscriptions when +;; written. Like React's useRef: holds mutable values between renders, and +;; provides imperative DOM element access via :ref attribute. + +(define ref + (fn (initial) + (dict "current" initial))) + +(define ref-get + (fn (r) + (get r "current"))) + +(define ref-set! + (fn (r v) + (dict-set! r "current" v))) + + +;; ========================================================================== +;; 13. Named stores — page-level signal containers (L3) ;; ========================================================================== ;; ;; Stores persist across island creation/destruction. They live at page diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index 6037331..d23f27b 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -129,11 +129,31 @@ (td :class "px-3 py-2 text-stone-700" "Reactive list") (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: map + deref auto-upgrades")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Input binding") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :bind signal, bind-input")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Keyed reconciliation") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :key attr, extract-key")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Reactive class/style") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :class-map, :style-map")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Refs") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: ref, ref-get, ref-set!, :ref attr")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Portals") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: portal render-dom form")) (tr - (td :class "px-3 py-2 text-stone-700" "Phase 2") - (td :class "px-3 py-2 text-amber-600 font-medium" "Planned") + (td :class "px-3 py-2 text-stone-700" "Phase 2 remaining") + (td :class "px-3 py-2 text-stone-500 font-medium" "P2") (td :class "px-3 py-2 font-mono text-xs text-stone-500" - (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Input binding, keyed lists, refs, portals, ..."))))))))) + (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries, suspense, transitions"))))))))) ;; --------------------------------------------------------------------------- ;; Live demo islands @@ -280,6 +300,75 @@ (p :class "text-sm text-green-700" "Thanks for agreeing!"))))) +;; 7. Reactive class/style — toggle classes and styles from signals +(defisland ~demo-class-map () + (let ((active (signal false)) + (size (signal 100))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3" + (div :class "flex items-center gap-3" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (swap! active not)) + "Toggle Active") + (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" + :on-click (fn (e) (swap! size (fn (s) (+ s 20)))) + "Grow") + (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" + :on-click (fn (e) (swap! size (fn (s) (max 40 (- s 20))))) + "Shrink")) + (div :class "flex justify-center" + (div + :class "rounded flex items-center justify-center font-mono text-sm transition-all duration-300" + :class-map (dict + "bg-violet-600" active + "text-white" active + "bg-stone-200" (computed (fn () (not (deref active)))) + "text-stone-700" (computed (fn () (not (deref active))))) + :style-map (dict + "width" (computed (fn () (str (deref size) "px"))) + "height" (computed (fn () (str (deref size) "px")))) + (if (deref active) "ON" "OFF")))))) + +;; 8. Refs — mutable boxes + DOM element access +(defisland ~demo-refs () + (let ((input-ref (ref nil)) + (count (signal 0))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3" + (div :class "flex items-center gap-3" + (input :type "text" :ref input-ref + :placeholder "Focus me with the button..." + :class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48") + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) + (do + (dom-focus (ref-get input-ref)) + (swap! count inc))) + "Focus Input") + (span :class "text-sm text-stone-500" + "Focused " (deref count) " times")) + (p :class "text-xs text-stone-400" + "The ref holds a mutable reference to the input element. Clicking the button calls focus() imperatively — no signal needed.")))) + +;; 9. Portal — render into a remote DOM target +(defisland ~demo-portal () + (let ((open? (signal false))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (swap! open? not)) + (if (deref open?) "Close Modal" "Open Modal")) + (portal "#portal-root" + (when (deref open?) + (div :class "fixed inset-0 bg-black/50 flex items-center justify-center z-50" + :on-click (fn (e) (reset! open? false)) + (div :class "bg-white rounded-lg p-6 max-w-md shadow-xl" + :on-click (fn (e) (stop-propagation e)) + (h2 :class "text-lg font-bold text-stone-800 mb-2" "Portal Modal") + (p :class "text-stone-600 text-sm mb-4" + "This content is rendered into " (code "#portal-root") " — outside the island's DOM subtree. It escapes overflow:hidden, z-index stacking, and layout constraints.") + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (reset! open? false)) + "Close")))))))) + + ;; --------------------------------------------------------------------------- ;; Demo page — shows what's been implemented ;; --------------------------------------------------------------------------- @@ -327,21 +416,41 @@ (~doc-code :code (highlight "(defisland ~demo-input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp")) (p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump.")) - (~doc-section :title "7. How defisland Works" :id "how-defisland" + (~doc-section :title "7. Reactive Class/Style" :id "demo-class-map" + (p (code ":class-map") " takes a dict of class names to signals. Each class is toggled reactively via " (code "classList.add/remove") ". " (code ":style-map") " takes a dict of style properties to signals. Both use a single effect that auto-tracks all dependencies.") + (~demo-class-map) + (~doc-code :code (highlight "(defisland ~demo-class-map ()\n (let ((active (signal false))\n (size (signal 100)))\n (div\n (button :on-click (fn (e) (swap! active not))\n \"Toggle Active\")\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 20))))\n \"Grow\")\n (div\n :class-map (dict\n \"bg-violet-600\" active\n \"text-white\" active\n \"bg-stone-200\" (computed (fn () (not (deref active)))))\n :style-map (dict\n \"width\" (computed (fn () (str (deref size) \"px\")))\n \"height\" (computed (fn () (str (deref size) \"px\"))))\n (if (deref active) \"ON\" \"OFF\")))))" "lisp")) + (p "Unlike " (code "reactive-attr") " (which replaces the entire attribute string), " (code "class-map") " uses " (code "classList.toggle") " per class — more efficient and doesn't clobber classes set by CSS transitions or third-party scripts.")) + + (~doc-section :title "8. Refs" :id "demo-refs" + (p "A " (code "ref") " is a mutable box that does " (em "not") " trigger reactivity. Like React's " (code "useRef") " — holds values between renders and provides imperative DOM access via " (code ":ref") " attribute.") + (~demo-refs) + (~doc-code :code (highlight "(defisland ~demo-refs ()\n (let ((input-ref (ref nil))\n (count (signal 0)))\n (div\n (input :type \"text\" :ref input-ref\n :placeholder \"Focus me with the button...\")\n (button :on-click (fn (e)\n (do\n (dom-focus (ref-get input-ref))\n (swap! count inc)))\n \"Focus Input\")\n (span \"Focused \" (deref count) \" times\"))))" "lisp")) + (p (code ":ref") " on an element sets " (code "ref.current") " to the DOM node after rendering. " (code "ref-get") " and " (code "ref-set!") " are non-reactive — writing to a ref doesn't trigger effects. Use refs for focus management, animations, canvas contexts, and anything requiring imperative DOM access.")) + + (~doc-section :title "9. Portals" :id "demo-portal" + (p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.") + (~demo-portal) + (~doc-code :code (highlight "(defisland ~demo-portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp")) + (p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup.")) + + (~doc-section :title "10. How defisland Works" :id "how-defisland" (p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.") (~doc-code :code (highlight ";; Definition — same syntax as defcomp\n(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~counter :initial 42)\n\n;; Server-side rendering:\n;;
\n;; 42\n;;
\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp")) (p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders.")) - (~doc-section :title "8. Test suite" :id "demo-tests" + (~doc-section :title "11. Test suite" :id "demo-tests" (p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).") (~doc-code :code (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp")) (p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals"))) (~doc-section :title "What's next" :id "next" - (p "The spec, bootstrappers, and wiring are complete. The full system is spec'd in " (code ".sx") " files and bootstrapped to JavaScript and Python. Remaining optimization:") + (p "Phase 1 and Phase 2 P0/P1 features are complete. The remaining P2 features are optional enhancements:") (ul :class "space-y-2 text-stone-600 list-disc pl-5" - (li (strong "Keyed list reconciliation") " — " (code "reactive-list") " currently clears and re-renders; needs keyed morph for efficient updates of large lists")) - (p "See the " (a :href "/reactive-islands/plan" :sx-get "/reactive-islands/plan" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "full plan") " for the complete design document.")))) + (li (strong "Error boundaries") " — catch errors in island subtrees, render fallback UI") + (li (strong "Suspense + resource") " — async-aware rendering with loading states") + (li (strong "Transitions") " — non-urgent signal updates for expensive re-renders")) + (p "See the " (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Phase 2 plan") " for details.")))) ;; --------------------------------------------------------------------------- @@ -581,11 +690,19 @@ (td :class "px-3 py-2 text-stone-700" "Reactive list") (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: map + deref auto-upgrades to reactive-list")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Input binding + keyed lists") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: :bind signal, :key attr")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Class/style + refs + portals") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 text-stone-700" ":class-map, :style-map, ref, :ref, portal")) (tr - (td :class "px-3 py-2 text-stone-700" "Phase 2") + (td :class "px-3 py-2 text-stone-700" "Phase 2 remaining") (td :class "px-3 py-2 text-violet-700 font-medium" - (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Planned →")) - (td :class "px-3 py-2 text-stone-700" "Input binding, keyed reconciliation, refs, portals, error boundaries, suspense, transitions")))))) + (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Details →")) + (td :class "px-3 py-2 text-stone-700" "Error boundaries, suspense, transitions")))))) (~doc-section :title "Design Principles" :id "principles" (ol :class "space-y-3 text-stone-600 list-decimal list-inside" @@ -621,27 +738,27 @@ (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Input binding") (td :class "px-3 py-2 text-stone-500 text-xs" "controlled inputs") - (td :class "px-3 py-2 text-red-700 font-medium" "P0") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Keyed reconciliation") (td :class "px-3 py-2 text-stone-500 text-xs" "key prop") - (td :class "px-3 py-2 text-red-700 font-medium" "P0") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Reactive class/style") (td :class "px-3 py-2 text-stone-500 text-xs" "className={...}") - (td :class "px-3 py-2 text-amber-600 font-medium" "P1") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Refs") (td :class "px-3 py-2 text-stone-500 text-xs" "useRef") - (td :class "px-3 py-2 text-amber-600 font-medium" "P1") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Portals") (td :class "px-3 py-2 text-stone-500 text-xs" "createPortal") - (td :class "px-3 py-2 text-amber-600 font-medium" "P1") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Error boundaries")