diff --git a/lib/hyperscript/compiler.sx b/lib/hyperscript/compiler.sx index f2b7eca0..800da93f 100644 --- a/lib/hyperscript/compiler.sx +++ b/lib/hyperscript/compiler.sx @@ -158,7 +158,13 @@ (define scan-on (fn - (items source filter every? catch-info finally-info) + (items + source + filter + every? + catch-info + finally-info + having-info) (cond ((<= (len items) 1) (let @@ -174,33 +180,28 @@ (let ((compiled-body (if (> (len event-refs) 0) (let ((bindings (map (fn (r) (let ((name (nth r 1))) (list (make-symbol name) (list (quote host-get) (list (quote host-get) (quote event) "detail") name)))) event-refs))) (list (quote let) bindings raw-compiled)) raw-compiled))) (let - ((wrapped-body (if catch-info (let ((var (make-symbol (nth catch-info 0))) (catch-body (hs-to-sx (nth catch-info 1)))) (if finally-info (list (quote do) (list (quote guard) (list var (list true catch-body)) compiled-body) (hs-to-sx finally-info)) (list (quote guard) (list var (list true catch-body)) compiled-body))) (if finally-info (list (quote do) compiled-body (hs-to-sx finally-info)) compiled-body))) - (handler - (let - ((uses-the-result? (fn (expr) (cond ((= expr (quote the-result)) true) ((list? expr) (some (fn (x) (uses-the-result? x)) expr)) (true false))))) + ((wrapped-body (if catch-info (let ((var (make-symbol (nth catch-info 0))) (catch-body (hs-to-sx (nth catch-info 1)))) (if finally-info (list (quote do) (list (quote guard) (list var (list true catch-body)) compiled-body) (hs-to-sx finally-info)) (list (quote guard) (list var (list true catch-body)) compiled-body))) (if finally-info (list (quote do) compiled-body (hs-to-sx finally-info)) compiled-body)))) + (let + ((handler (let ((uses-the-result? (fn (expr) (cond ((= expr (quote the-result)) true) ((list? expr) (some (fn (x) (uses-the-result? x)) expr)) (true false))))) (list (quote fn) (list (quote event)) (if (uses-the-result? wrapped-body) (list (quote let) (list (list (quote the-result) nil)) wrapped-body) wrapped-body))))) + (let + ((on-call (if every? (list (quote hs-on-every) target event-name handler) (list (quote hs-on) target event-name handler)))) + (if + (= event-name "intersection") (list - (quote fn) - (list (quote event)) - (if - (uses-the-result? wrapped-body) - (list - (quote let) - (list - (list (quote the-result) nil)) - wrapped-body) - wrapped-body))))) - (if - every? - (list - (quote hs-on-every) - target - event-name - handler) - (list - (quote hs-on) - target - event-name - handler)))))))))) + (quote do) + on-call + (list + (quote hs-on-intersection-attach!) + target + (if + having-info + (get having-info "margin") + nil) + (if + having-info + (get having-info "threshold") + nil))) + on-call))))))))))) ((= (first items) :from) (scan-on (rest (rest items)) @@ -208,7 +209,8 @@ filter every? catch-info - finally-info)) + finally-info + having-info)) ((= (first items) :filter) (scan-on (rest (rest items)) @@ -216,7 +218,8 @@ (nth items 1) every? catch-info - finally-info)) + finally-info + having-info)) ((= (first items) :every) (scan-on (rest (rest items)) @@ -224,7 +227,8 @@ filter true catch-info - finally-info)) + finally-info + having-info)) ((= (first items) :catch) (scan-on (rest (rest items)) @@ -232,7 +236,8 @@ filter every? (nth items 1) - finally-info)) + finally-info + having-info)) ((= (first items) :finally) (scan-on (rest (rest items)) @@ -240,6 +245,16 @@ filter every? catch-info + (nth items 1) + having-info)) + ((= (first items) :having) + (scan-on + (rest (rest items)) + source + filter + every? + catch-info + finally-info (nth items 1))) (true (scan-on @@ -248,8 +263,9 @@ filter every? catch-info - finally-info))))) - (scan-on (rest parts) nil nil false nil nil))))) + finally-info + having-info))))) + (scan-on (rest parts) nil nil false nil nil nil))))) (define emit-send (fn diff --git a/lib/hyperscript/parser.sx b/lib/hyperscript/parser.sx index ce97ef38..c009c2b1 100644 --- a/lib/hyperscript/parser.sx +++ b/lib/hyperscript/parser.sx @@ -2543,27 +2543,55 @@ (let ((source (if (match-kw "from") (parse-expr) nil))) (let - ((body (parse-cmd-list))) + ((h-margin nil) (h-threshold nil)) + (define + consume-having! + (fn + () + (cond + ((and (= (tp-type) "ident") (= (tp-val) "having")) + (do + (adv!) + (cond + ((and (= (tp-type) "ident") (= (tp-val) "margin")) + (do + (adv!) + (set! h-margin (parse-expr)) + (consume-having!))) + ((and (= (tp-type) "ident") (= (tp-val) "threshold")) + (do + (adv!) + (set! h-threshold (parse-expr)) + (consume-having!))) + (true nil)))) + (true nil)))) + (consume-having!) (let - ((catch-clause (if (match-kw "catch") (let ((var (let ((v (tp-val))) (adv!) v)) (handler (parse-cmd-list))) (list var handler)) nil)) - (finally-clause - (if (match-kw "finally") (parse-cmd-list) nil))) - (match-kw "end") + ((having (if (or h-margin h-threshold) (dict "margin" h-margin "threshold" h-threshold) nil))) (let - ((parts (list (quote on) event-name))) + ((body (parse-cmd-list))) (let - ((parts (if every? (append parts (list :every true)) parts))) + ((catch-clause (if (match-kw "catch") (let ((var (let ((v (tp-val))) (adv!) v)) (handler (parse-cmd-list))) (list var handler)) nil)) + (finally-clause + (if (match-kw "finally") (parse-cmd-list) nil))) + (match-kw "end") (let - ((parts (if flt (append parts (list :filter flt)) parts))) + ((parts (list (quote on) event-name))) (let - ((parts (if source (append parts (list :from source)) parts))) + ((parts (if every? (append parts (list :every true)) parts))) (let - ((parts (if catch-clause (append parts (list :catch catch-clause)) parts))) + ((parts (if flt (append parts (list :filter flt)) parts))) (let - ((parts (if finally-clause (append parts (list :finally finally-clause)) parts))) + ((parts (if source (append parts (list :from source)) parts))) (let - ((parts (append parts (list body)))) - parts))))))))))))))) + ((parts (if having (append parts (list :having having)) parts))) + (let + ((parts (if catch-clause (append parts (list :catch catch-clause)) parts))) + (let + ((parts (if finally-clause (append parts (list :finally finally-clause)) parts))) + (let + ((parts (append parts (list body)))) + parts)))))))))))))))))) (define parse-init-feat (fn diff --git a/lib/hyperscript/runtime.sx b/lib/hyperscript/runtime.sx index 04889425..72ea0918 100644 --- a/lib/hyperscript/runtime.sx +++ b/lib/hyperscript/runtime.sx @@ -66,14 +66,30 @@ ;; Wait for a DOM event on a target. ;; (hs-wait-for target event-name) — suspends until event fires -(define hs-init (fn (thunk) (thunk))) +(define + hs-on-intersection-attach! + (fn + (target margin threshold) + (let + ((opts (dict))) + (when margin (dict-set! opts "rootMargin" margin)) + (when threshold (dict-set! opts "threshold" threshold)) + (let + ((cb (fn (entries observer) (let ((entry (if (> (len entries) 0) (nth entries 0) nil))) (when entry (let ((intersecting (host-get entry "isIntersecting"))) (dom-dispatch target "intersection" (dict "intersecting" intersecting "entry" entry)))))))) + (let + ((observer (host-new "IntersectionObserver" cb opts))) + (host-call observer "observe" target) + observer))))) ;; Wait for CSS transitions/animations to settle on an element. -(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms)))) +(define hs-init (fn (thunk) (thunk))) ;; ── Class manipulation ────────────────────────────────────────── ;; Toggle a single class on an element. +(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms)))) + +;; Toggle between two classes — exactly one is active at a time. (begin (define hs-wait-for @@ -86,19 +102,21 @@ (target event-name timeout-ms) (perform (list (quote io-wait-event) target event-name timeout-ms))))) -;; Toggle between two classes — exactly one is active at a time. -(define hs-settle (fn (target) (perform (list (quote io-settle) target)))) - ;; Take a class from siblings — add to target, remove from others. ;; (hs-take! target cls) — like radio button class behavior -(define - hs-toggle-class! - (fn (target cls) (host-call (host-get target "classList") "toggle" cls))) +(define hs-settle (fn (target) (perform (list (quote io-settle) target)))) ;; ── DOM insertion ─────────────────────────────────────────────── ;; Put content at a position relative to a target. ;; pos: "into" | "before" | "after" +(define + hs-toggle-class! + (fn (target cls) (host-call (host-get target "classList") "toggle" cls))) + +;; ── Navigation / traversal ────────────────────────────────────── + +;; Navigate to a URL. (define hs-toggle-between! (fn @@ -108,9 +126,7 @@ (do (dom-remove-class target cls1) (dom-add-class target cls2)) (do (dom-remove-class target cls2) (dom-add-class target cls1))))) -;; ── Navigation / traversal ────────────────────────────────────── - -;; Navigate to a URL. +;; Find next sibling matching a selector (or any sibling). (define hs-toggle-style! (fn @@ -134,7 +150,7 @@ (dom-set-style target prop "hidden") (dom-set-style target prop ""))))))) -;; Find next sibling matching a selector (or any sibling). +;; Find previous sibling matching a selector. (define hs-toggle-style-between! (fn @@ -146,7 +162,7 @@ (dom-set-style target prop val2) (dom-set-style target prop val1))))) -;; Find previous sibling matching a selector. +;; First element matching selector within a scope. (define hs-toggle-style-cycle! (fn @@ -167,7 +183,7 @@ (true (find-next (rest remaining)))))) (dom-set-style target prop (find-next vals))))) -;; First element matching selector within a scope. +;; Last element matching selector. (define hs-take! (fn @@ -207,7 +223,7 @@ (dom-set-attr target name attr-val) (dom-set-attr target name "")))))))) -;; Last element matching selector. +;; First/last within a specific scope. (begin (define hs-element? @@ -319,7 +335,6 @@ (dom-insert-adjacent-html target "beforeend" value) (hs-boot-subtree! target))))))))) -;; First/last within a specific scope. (define hs-add-to! (fn @@ -332,6 +347,9 @@ (append target (list value)))) (true (do (host-call target "push" value) target))))) +;; ── Iteration ─────────────────────────────────────────────────── + +;; Repeat a thunk N times. (define hs-remove-from! (fn @@ -341,9 +359,7 @@ (filter (fn (x) (not (= x value))) target) (host-call target "splice" (host-call target "indexOf" value) 1)))) -;; ── Iteration ─────────────────────────────────────────────────── - -;; Repeat a thunk N times. +;; Repeat forever (until break — relies on exception/continuation). (define hs-splice-at! (fn @@ -367,7 +383,10 @@ (host-call target "splice" i 1)))) target)))) -;; Repeat forever (until break — relies on exception/continuation). +;; ── Fetch ─────────────────────────────────────────────────────── + +;; Fetch a URL, parse response according to format. +;; (hs-fetch url format) — format is "json" | "text" | "html" (define hs-put-at! (fn @@ -386,10 +405,10 @@ ((= pos "start") (host-call target "unshift" value))) target))))) -;; ── Fetch ─────────────────────────────────────────────────────── +;; ── Type coercion ─────────────────────────────────────────────── -;; Fetch a URL, parse response according to format. -;; (hs-fetch url format) — format is "json" | "text" | "html" +;; Coerce a value to a type by name. +;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc. (define hs-dict-without (fn @@ -410,27 +429,27 @@ (host-call (host-global "Reflect") "deleteProperty" out key) out))))) -;; ── Type coercion ─────────────────────────────────────────────── +;; ── Object creation ───────────────────────────────────────────── -;; Coerce a value to a type by name. -;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc. +;; Make a new object of a given type. +;; (hs-make type-name) — creates empty object/collection (define hs-set-on! (fn (props target) (for-each (fn (k) (host-set! target k (get props k))) (keys props)))) -;; ── Object creation ───────────────────────────────────────────── - -;; Make a new object of a given type. -;; (hs-make type-name) — creates empty object/collection -(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url)))) - ;; ── Behavior installation ─────────────────────────────────────── ;; Install a behavior on an element. ;; A behavior is a function that takes (me ...params) and sets up features. ;; (hs-install behavior-fn me ...args) +(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url)))) + +;; ── Measurement ───────────────────────────────────────────────── + +;; Measure an element's bounding rect, store as local variables. +;; Returns a dict with x, y, width, height, top, left, right, bottom. (define hs-scroll! (fn @@ -443,10 +462,10 @@ ((= position "bottom") (dict :block "end")) (true (dict :block "start"))))))) -;; ── Measurement ───────────────────────────────────────────────── - -;; Measure an element's bounding rect, store as local variables. -;; Returns a dict with x, y, width, height, top, left, right, bottom. +;; Return the current text selection as a string. In the browser this is +;; `window.getSelection().toString()`. In the mock test runner, a test +;; setup stashes the desired selection text at `window.__test_selection` +;; and the fallback path returns that so tests can assert on the result. (define hs-halt! (fn @@ -467,17 +486,13 @@ (host-call ev "stopPropagation"))))) (when (not (= mode "the-event")) (raise (list "hs-return" nil)))))) -;; Return the current text selection as a string. In the browser this is -;; `window.getSelection().toString()`. In the mock test runner, a test -;; setup stashes the desired selection text at `window.__test_selection` -;; and the fallback path returns that so tests can assert on the result. -(define hs-select! (fn (target) (host-call target "select" (list)))) - ;; ── Transition ────────────────────────────────────────────────── ;; Transition a CSS property to a value, optionally with duration. ;; (hs-transition target prop value duration) +(define hs-select! (fn (target) (host-call target "select" (list)))) + (define hs-get-selection (fn @@ -612,12 +627,12 @@ ((all (dom-query-all (dom-body) sel))) (if (> (len all) 0) (nth all (- (len all) 1)) nil)))) + + + + (define hs-first (fn (scope sel) (dom-query-all scope sel))) - - - - (define hs-last (fn @@ -625,7 +640,8 @@ (let ((all (dom-query-all scope sel))) (if (> (len all) 0) (nth all (- (len all) 1)) nil)))) - +;; ── Sandbox/test runtime additions ────────────────────────────── +;; Property access — dot notation and .length (define hs-repeat-times (fn @@ -643,8 +659,7 @@ ((= signal "hs-continue") (do-repeat (+ i 1))) (true (do-repeat (+ i 1)))))))) (do-repeat 0))) -;; ── Sandbox/test runtime additions ────────────────────────────── -;; Property access — dot notation and .length +;; DOM query stub — sandbox returns empty list (define hs-repeat-forever (fn @@ -660,7 +675,7 @@ ((= signal "hs-continue") (do-forever)) (true (do-forever)))))) (do-forever))) -;; DOM query stub — sandbox returns empty list +;; Method dispatch — obj.method(args) (define hs-repeat-while (fn @@ -673,7 +688,9 @@ ((= signal "hs-break") nil) ((= signal "hs-continue") (hs-repeat-while cond-fn thunk)) (true (hs-repeat-while cond-fn thunk))))))) -;; Method dispatch — obj.method(args) + +;; ── 0.9.90 features ───────────────────────────────────────────── +;; beep! — debug logging, returns value unchanged (define hs-repeat-until (fn @@ -685,9 +702,7 @@ ((= signal "hs-continue") (if (cond-fn) nil (hs-repeat-until cond-fn thunk))) (true (if (cond-fn) nil (hs-repeat-until cond-fn thunk))))))) - -;; ── 0.9.90 features ───────────────────────────────────────────── -;; beep! — debug logging, returns value unchanged +;; Property-based is — check obj.key truthiness (define hs-for-each (fn @@ -707,7 +722,7 @@ ((= signal "hs-continue") (do-loop (rest remaining))) (true (do-loop (rest remaining)))))))) (do-loop items)))) -;; Property-based is — check obj.key truthiness +;; Array slicing (inclusive both ends) (begin (define hs-append @@ -735,7 +750,7 @@ ((hs-element? target) (dom-insert-adjacent-html target "beforeend" (str value))) (true nil))))) -;; Array slicing (inclusive both ends) +;; Collection: sorted by (define hs-sender (fn @@ -743,7 +758,7 @@ (let ((detail (host-get event "detail"))) (if detail (host-get detail "sender") nil)))) -;; Collection: sorted by +;; Collection: sorted by descending (define hs-host-to-sx (fn @@ -797,7 +812,7 @@ (dict-set! out k (hs-host-to-sx (host-get v k)))) (host-call (host-global "Object") "keys" v)) out))))))))))) -;; Collection: sorted by descending +;; Collection: split by (define hs-fetch (fn @@ -807,7 +822,7 @@ (let ((raw (perform (list "io-fetch" url fmt)))) (cond ((= fmt "json") (hs-host-to-sx raw)) (true raw)))))) -;; Collection: split by +;; Collection: joined by (define hs-json-escape (fn @@ -838,7 +853,7 @@ (walk) out) "\""))) -;; Collection: joined by + (define hs-json-stringify (fn diff --git a/shared/static/wasm/sx/hs-compiler.sx b/shared/static/wasm/sx/hs-compiler.sx index f2b7eca0..800da93f 100644 --- a/shared/static/wasm/sx/hs-compiler.sx +++ b/shared/static/wasm/sx/hs-compiler.sx @@ -158,7 +158,13 @@ (define scan-on (fn - (items source filter every? catch-info finally-info) + (items + source + filter + every? + catch-info + finally-info + having-info) (cond ((<= (len items) 1) (let @@ -174,33 +180,28 @@ (let ((compiled-body (if (> (len event-refs) 0) (let ((bindings (map (fn (r) (let ((name (nth r 1))) (list (make-symbol name) (list (quote host-get) (list (quote host-get) (quote event) "detail") name)))) event-refs))) (list (quote let) bindings raw-compiled)) raw-compiled))) (let - ((wrapped-body (if catch-info (let ((var (make-symbol (nth catch-info 0))) (catch-body (hs-to-sx (nth catch-info 1)))) (if finally-info (list (quote do) (list (quote guard) (list var (list true catch-body)) compiled-body) (hs-to-sx finally-info)) (list (quote guard) (list var (list true catch-body)) compiled-body))) (if finally-info (list (quote do) compiled-body (hs-to-sx finally-info)) compiled-body))) - (handler - (let - ((uses-the-result? (fn (expr) (cond ((= expr (quote the-result)) true) ((list? expr) (some (fn (x) (uses-the-result? x)) expr)) (true false))))) + ((wrapped-body (if catch-info (let ((var (make-symbol (nth catch-info 0))) (catch-body (hs-to-sx (nth catch-info 1)))) (if finally-info (list (quote do) (list (quote guard) (list var (list true catch-body)) compiled-body) (hs-to-sx finally-info)) (list (quote guard) (list var (list true catch-body)) compiled-body))) (if finally-info (list (quote do) compiled-body (hs-to-sx finally-info)) compiled-body)))) + (let + ((handler (let ((uses-the-result? (fn (expr) (cond ((= expr (quote the-result)) true) ((list? expr) (some (fn (x) (uses-the-result? x)) expr)) (true false))))) (list (quote fn) (list (quote event)) (if (uses-the-result? wrapped-body) (list (quote let) (list (list (quote the-result) nil)) wrapped-body) wrapped-body))))) + (let + ((on-call (if every? (list (quote hs-on-every) target event-name handler) (list (quote hs-on) target event-name handler)))) + (if + (= event-name "intersection") (list - (quote fn) - (list (quote event)) - (if - (uses-the-result? wrapped-body) - (list - (quote let) - (list - (list (quote the-result) nil)) - wrapped-body) - wrapped-body))))) - (if - every? - (list - (quote hs-on-every) - target - event-name - handler) - (list - (quote hs-on) - target - event-name - handler)))))))))) + (quote do) + on-call + (list + (quote hs-on-intersection-attach!) + target + (if + having-info + (get having-info "margin") + nil) + (if + having-info + (get having-info "threshold") + nil))) + on-call))))))))))) ((= (first items) :from) (scan-on (rest (rest items)) @@ -208,7 +209,8 @@ filter every? catch-info - finally-info)) + finally-info + having-info)) ((= (first items) :filter) (scan-on (rest (rest items)) @@ -216,7 +218,8 @@ (nth items 1) every? catch-info - finally-info)) + finally-info + having-info)) ((= (first items) :every) (scan-on (rest (rest items)) @@ -224,7 +227,8 @@ filter true catch-info - finally-info)) + finally-info + having-info)) ((= (first items) :catch) (scan-on (rest (rest items)) @@ -232,7 +236,8 @@ filter every? (nth items 1) - finally-info)) + finally-info + having-info)) ((= (first items) :finally) (scan-on (rest (rest items)) @@ -240,6 +245,16 @@ filter every? catch-info + (nth items 1) + having-info)) + ((= (first items) :having) + (scan-on + (rest (rest items)) + source + filter + every? + catch-info + finally-info (nth items 1))) (true (scan-on @@ -248,8 +263,9 @@ filter every? catch-info - finally-info))))) - (scan-on (rest parts) nil nil false nil nil))))) + finally-info + having-info))))) + (scan-on (rest parts) nil nil false nil nil nil))))) (define emit-send (fn diff --git a/shared/static/wasm/sx/hs-parser.sx b/shared/static/wasm/sx/hs-parser.sx index ce97ef38..c009c2b1 100644 --- a/shared/static/wasm/sx/hs-parser.sx +++ b/shared/static/wasm/sx/hs-parser.sx @@ -2543,27 +2543,55 @@ (let ((source (if (match-kw "from") (parse-expr) nil))) (let - ((body (parse-cmd-list))) + ((h-margin nil) (h-threshold nil)) + (define + consume-having! + (fn + () + (cond + ((and (= (tp-type) "ident") (= (tp-val) "having")) + (do + (adv!) + (cond + ((and (= (tp-type) "ident") (= (tp-val) "margin")) + (do + (adv!) + (set! h-margin (parse-expr)) + (consume-having!))) + ((and (= (tp-type) "ident") (= (tp-val) "threshold")) + (do + (adv!) + (set! h-threshold (parse-expr)) + (consume-having!))) + (true nil)))) + (true nil)))) + (consume-having!) (let - ((catch-clause (if (match-kw "catch") (let ((var (let ((v (tp-val))) (adv!) v)) (handler (parse-cmd-list))) (list var handler)) nil)) - (finally-clause - (if (match-kw "finally") (parse-cmd-list) nil))) - (match-kw "end") + ((having (if (or h-margin h-threshold) (dict "margin" h-margin "threshold" h-threshold) nil))) (let - ((parts (list (quote on) event-name))) + ((body (parse-cmd-list))) (let - ((parts (if every? (append parts (list :every true)) parts))) + ((catch-clause (if (match-kw "catch") (let ((var (let ((v (tp-val))) (adv!) v)) (handler (parse-cmd-list))) (list var handler)) nil)) + (finally-clause + (if (match-kw "finally") (parse-cmd-list) nil))) + (match-kw "end") (let - ((parts (if flt (append parts (list :filter flt)) parts))) + ((parts (list (quote on) event-name))) (let - ((parts (if source (append parts (list :from source)) parts))) + ((parts (if every? (append parts (list :every true)) parts))) (let - ((parts (if catch-clause (append parts (list :catch catch-clause)) parts))) + ((parts (if flt (append parts (list :filter flt)) parts))) (let - ((parts (if finally-clause (append parts (list :finally finally-clause)) parts))) + ((parts (if source (append parts (list :from source)) parts))) (let - ((parts (append parts (list body)))) - parts))))))))))))))) + ((parts (if having (append parts (list :having having)) parts))) + (let + ((parts (if catch-clause (append parts (list :catch catch-clause)) parts))) + (let + ((parts (if finally-clause (append parts (list :finally finally-clause)) parts))) + (let + ((parts (append parts (list body)))) + parts)))))))))))))))))) (define parse-init-feat (fn diff --git a/shared/static/wasm/sx/hs-runtime.sx b/shared/static/wasm/sx/hs-runtime.sx index 04889425..72ea0918 100644 --- a/shared/static/wasm/sx/hs-runtime.sx +++ b/shared/static/wasm/sx/hs-runtime.sx @@ -66,14 +66,30 @@ ;; Wait for a DOM event on a target. ;; (hs-wait-for target event-name) — suspends until event fires -(define hs-init (fn (thunk) (thunk))) +(define + hs-on-intersection-attach! + (fn + (target margin threshold) + (let + ((opts (dict))) + (when margin (dict-set! opts "rootMargin" margin)) + (when threshold (dict-set! opts "threshold" threshold)) + (let + ((cb (fn (entries observer) (let ((entry (if (> (len entries) 0) (nth entries 0) nil))) (when entry (let ((intersecting (host-get entry "isIntersecting"))) (dom-dispatch target "intersection" (dict "intersecting" intersecting "entry" entry)))))))) + (let + ((observer (host-new "IntersectionObserver" cb opts))) + (host-call observer "observe" target) + observer))))) ;; Wait for CSS transitions/animations to settle on an element. -(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms)))) +(define hs-init (fn (thunk) (thunk))) ;; ── Class manipulation ────────────────────────────────────────── ;; Toggle a single class on an element. +(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms)))) + +;; Toggle between two classes — exactly one is active at a time. (begin (define hs-wait-for @@ -86,19 +102,21 @@ (target event-name timeout-ms) (perform (list (quote io-wait-event) target event-name timeout-ms))))) -;; Toggle between two classes — exactly one is active at a time. -(define hs-settle (fn (target) (perform (list (quote io-settle) target)))) - ;; Take a class from siblings — add to target, remove from others. ;; (hs-take! target cls) — like radio button class behavior -(define - hs-toggle-class! - (fn (target cls) (host-call (host-get target "classList") "toggle" cls))) +(define hs-settle (fn (target) (perform (list (quote io-settle) target)))) ;; ── DOM insertion ─────────────────────────────────────────────── ;; Put content at a position relative to a target. ;; pos: "into" | "before" | "after" +(define + hs-toggle-class! + (fn (target cls) (host-call (host-get target "classList") "toggle" cls))) + +;; ── Navigation / traversal ────────────────────────────────────── + +;; Navigate to a URL. (define hs-toggle-between! (fn @@ -108,9 +126,7 @@ (do (dom-remove-class target cls1) (dom-add-class target cls2)) (do (dom-remove-class target cls2) (dom-add-class target cls1))))) -;; ── Navigation / traversal ────────────────────────────────────── - -;; Navigate to a URL. +;; Find next sibling matching a selector (or any sibling). (define hs-toggle-style! (fn @@ -134,7 +150,7 @@ (dom-set-style target prop "hidden") (dom-set-style target prop ""))))))) -;; Find next sibling matching a selector (or any sibling). +;; Find previous sibling matching a selector. (define hs-toggle-style-between! (fn @@ -146,7 +162,7 @@ (dom-set-style target prop val2) (dom-set-style target prop val1))))) -;; Find previous sibling matching a selector. +;; First element matching selector within a scope. (define hs-toggle-style-cycle! (fn @@ -167,7 +183,7 @@ (true (find-next (rest remaining)))))) (dom-set-style target prop (find-next vals))))) -;; First element matching selector within a scope. +;; Last element matching selector. (define hs-take! (fn @@ -207,7 +223,7 @@ (dom-set-attr target name attr-val) (dom-set-attr target name "")))))))) -;; Last element matching selector. +;; First/last within a specific scope. (begin (define hs-element? @@ -319,7 +335,6 @@ (dom-insert-adjacent-html target "beforeend" value) (hs-boot-subtree! target))))))))) -;; First/last within a specific scope. (define hs-add-to! (fn @@ -332,6 +347,9 @@ (append target (list value)))) (true (do (host-call target "push" value) target))))) +;; ── Iteration ─────────────────────────────────────────────────── + +;; Repeat a thunk N times. (define hs-remove-from! (fn @@ -341,9 +359,7 @@ (filter (fn (x) (not (= x value))) target) (host-call target "splice" (host-call target "indexOf" value) 1)))) -;; ── Iteration ─────────────────────────────────────────────────── - -;; Repeat a thunk N times. +;; Repeat forever (until break — relies on exception/continuation). (define hs-splice-at! (fn @@ -367,7 +383,10 @@ (host-call target "splice" i 1)))) target)))) -;; Repeat forever (until break — relies on exception/continuation). +;; ── Fetch ─────────────────────────────────────────────────────── + +;; Fetch a URL, parse response according to format. +;; (hs-fetch url format) — format is "json" | "text" | "html" (define hs-put-at! (fn @@ -386,10 +405,10 @@ ((= pos "start") (host-call target "unshift" value))) target))))) -;; ── Fetch ─────────────────────────────────────────────────────── +;; ── Type coercion ─────────────────────────────────────────────── -;; Fetch a URL, parse response according to format. -;; (hs-fetch url format) — format is "json" | "text" | "html" +;; Coerce a value to a type by name. +;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc. (define hs-dict-without (fn @@ -410,27 +429,27 @@ (host-call (host-global "Reflect") "deleteProperty" out key) out))))) -;; ── Type coercion ─────────────────────────────────────────────── +;; ── Object creation ───────────────────────────────────────────── -;; Coerce a value to a type by name. -;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc. +;; Make a new object of a given type. +;; (hs-make type-name) — creates empty object/collection (define hs-set-on! (fn (props target) (for-each (fn (k) (host-set! target k (get props k))) (keys props)))) -;; ── Object creation ───────────────────────────────────────────── - -;; Make a new object of a given type. -;; (hs-make type-name) — creates empty object/collection -(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url)))) - ;; ── Behavior installation ─────────────────────────────────────── ;; Install a behavior on an element. ;; A behavior is a function that takes (me ...params) and sets up features. ;; (hs-install behavior-fn me ...args) +(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url)))) + +;; ── Measurement ───────────────────────────────────────────────── + +;; Measure an element's bounding rect, store as local variables. +;; Returns a dict with x, y, width, height, top, left, right, bottom. (define hs-scroll! (fn @@ -443,10 +462,10 @@ ((= position "bottom") (dict :block "end")) (true (dict :block "start"))))))) -;; ── Measurement ───────────────────────────────────────────────── - -;; Measure an element's bounding rect, store as local variables. -;; Returns a dict with x, y, width, height, top, left, right, bottom. +;; Return the current text selection as a string. In the browser this is +;; `window.getSelection().toString()`. In the mock test runner, a test +;; setup stashes the desired selection text at `window.__test_selection` +;; and the fallback path returns that so tests can assert on the result. (define hs-halt! (fn @@ -467,17 +486,13 @@ (host-call ev "stopPropagation"))))) (when (not (= mode "the-event")) (raise (list "hs-return" nil)))))) -;; Return the current text selection as a string. In the browser this is -;; `window.getSelection().toString()`. In the mock test runner, a test -;; setup stashes the desired selection text at `window.__test_selection` -;; and the fallback path returns that so tests can assert on the result. -(define hs-select! (fn (target) (host-call target "select" (list)))) - ;; ── Transition ────────────────────────────────────────────────── ;; Transition a CSS property to a value, optionally with duration. ;; (hs-transition target prop value duration) +(define hs-select! (fn (target) (host-call target "select" (list)))) + (define hs-get-selection (fn @@ -612,12 +627,12 @@ ((all (dom-query-all (dom-body) sel))) (if (> (len all) 0) (nth all (- (len all) 1)) nil)))) + + + + (define hs-first (fn (scope sel) (dom-query-all scope sel))) - - - - (define hs-last (fn @@ -625,7 +640,8 @@ (let ((all (dom-query-all scope sel))) (if (> (len all) 0) (nth all (- (len all) 1)) nil)))) - +;; ── Sandbox/test runtime additions ────────────────────────────── +;; Property access — dot notation and .length (define hs-repeat-times (fn @@ -643,8 +659,7 @@ ((= signal "hs-continue") (do-repeat (+ i 1))) (true (do-repeat (+ i 1)))))))) (do-repeat 0))) -;; ── Sandbox/test runtime additions ────────────────────────────── -;; Property access — dot notation and .length +;; DOM query stub — sandbox returns empty list (define hs-repeat-forever (fn @@ -660,7 +675,7 @@ ((= signal "hs-continue") (do-forever)) (true (do-forever)))))) (do-forever))) -;; DOM query stub — sandbox returns empty list +;; Method dispatch — obj.method(args) (define hs-repeat-while (fn @@ -673,7 +688,9 @@ ((= signal "hs-break") nil) ((= signal "hs-continue") (hs-repeat-while cond-fn thunk)) (true (hs-repeat-while cond-fn thunk))))))) -;; Method dispatch — obj.method(args) + +;; ── 0.9.90 features ───────────────────────────────────────────── +;; beep! — debug logging, returns value unchanged (define hs-repeat-until (fn @@ -685,9 +702,7 @@ ((= signal "hs-continue") (if (cond-fn) nil (hs-repeat-until cond-fn thunk))) (true (if (cond-fn) nil (hs-repeat-until cond-fn thunk))))))) - -;; ── 0.9.90 features ───────────────────────────────────────────── -;; beep! — debug logging, returns value unchanged +;; Property-based is — check obj.key truthiness (define hs-for-each (fn @@ -707,7 +722,7 @@ ((= signal "hs-continue") (do-loop (rest remaining))) (true (do-loop (rest remaining)))))))) (do-loop items)))) -;; Property-based is — check obj.key truthiness +;; Array slicing (inclusive both ends) (begin (define hs-append @@ -735,7 +750,7 @@ ((hs-element? target) (dom-insert-adjacent-html target "beforeend" (str value))) (true nil))))) -;; Array slicing (inclusive both ends) +;; Collection: sorted by (define hs-sender (fn @@ -743,7 +758,7 @@ (let ((detail (host-get event "detail"))) (if detail (host-get detail "sender") nil)))) -;; Collection: sorted by +;; Collection: sorted by descending (define hs-host-to-sx (fn @@ -797,7 +812,7 @@ (dict-set! out k (hs-host-to-sx (host-get v k)))) (host-call (host-global "Object") "keys" v)) out))))))))))) -;; Collection: sorted by descending +;; Collection: split by (define hs-fetch (fn @@ -807,7 +822,7 @@ (let ((raw (perform (list "io-fetch" url fmt)))) (cond ((= fmt "json") (hs-host-to-sx raw)) (true raw)))))) -;; Collection: split by +;; Collection: joined by (define hs-json-escape (fn @@ -838,7 +853,7 @@ (walk) out) "\""))) -;; Collection: joined by + (define hs-json-stringify (fn diff --git a/tests/hs-run-filtered.js b/tests/hs-run-filtered.js index 049501f6..de8bff65 100755 --- a/tests/hs-run-filtered.js +++ b/tests/hs-run-filtered.js @@ -354,7 +354,26 @@ class HsResizeObserver { } } globalThis.ResizeObserver=HsResizeObserver; globalThis.ResizeObserverEntry=class{}; -globalThis.IntersectionObserver=class{observe(){}disconnect(){}}; +// HsIntersectionObserver — cluster-27 intersection mock. Fires the callback +// synchronously on observe() with isIntersecting=true so `on intersection` +// handlers run during activation. `margin`/`threshold` options are parsed +// but ignored (tests only assert the handler fires). +class HsIntersectionObserver { + constructor(cb, opts) { this._cb = cb; this._opts = opts || {}; this._els = new Set(); } + observe(el) { + if (!el) return; + this._els.add(el); + const entry = { target: el, isIntersecting: true, intersectionRatio: 1, + boundingClientRect: (el.getBoundingClientRect && el.getBoundingClientRect()) || {}, + intersectionRect: {}, rootBounds: null, time: 0 }; + try { this._cb([entry], this); } catch (e) {} + } + unobserve(el) { if (el) this._els.delete(el); } + disconnect() { this._els.clear(); } + takeRecords() { return []; } +} +globalThis.IntersectionObserver = HsIntersectionObserver; +globalThis.IntersectionObserverEntry = class {}; globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''}; globalThis.history={pushState(){},replaceState(){},back(){},forward(){}}; globalThis.getSelection=()=>({toString:()=>(globalThis.__test_selection||'')});