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

@@ -662,6 +662,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
<div id="portal-root"></div>
</body>
</html>"""

View File

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

View File

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

View File

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