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 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:27:55 +00:00
parent 3103d7ff9d
commit 6bda2bafa2
7 changed files with 357 additions and 29 deletions

View File

@@ -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) {

View File

@@ -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) {