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:
@@ -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;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\":42}'>\n;; <span>42</span><button>+</button>\n;; </div>\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")
|
||||
|
||||
Reference in New Issue
Block a user