diff --git a/uni-dom-test/src/test/scala/wvlet/uni/dom/ClickOutsideTest.scala b/uni-dom-test/src/test/scala/wvlet/uni/dom/ClickOutsideTest.scala new file mode 100644 index 0000000..d228a37 --- /dev/null +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/ClickOutsideTest.scala @@ -0,0 +1,64 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import wvlet.uni.test.UniTest +import wvlet.uni.rx.Rx +import wvlet.uni.dom.all.* +import wvlet.uni.dom.all.given + +class ClickOutsideTest extends UniTest: + + test("ClickOutside.detect creates ClickOutsideBinding"): + val binding = ClickOutside.detect(_ => ()) + binding shouldMatch { case _: ClickOutsideBinding => + } + + test("ClickOutside.hide creates ClickOutsideBinding"): + val visible = Rx.variable(true) + val binding = ClickOutside.hide(visible) + binding shouldMatch { case _: ClickOutsideBinding => + } + + test("ClickOutside.onClickOutside creates ClickOutsideBinding"): + var called = false + val binding = ClickOutside.onClickOutside(() => called = true) + binding shouldMatch { case _: ClickOutsideBinding => + } + + test("ClickOutsideBinding stores callback"): + var received: Option[org.scalajs.dom.MouseEvent] = None + val binding = ClickOutside.detect(e => received = Some(e)) + binding shouldMatch { case cb: ClickOutsideBinding => + cb.callback shouldMatch { case _: Function1[?, ?] => + } + } + + test("ClickOutside.detect can be used as DomNode modifier"): + val elem = div(ClickOutside.detect(_ => ()), span("content")) + elem shouldMatch { case _: DomElement => + } + + test("ClickOutside.hide can be used as DomNode modifier"): + val visible = Rx.variable(true) + val elem = div(ClickOutside.hide(visible), span("content")) + elem shouldMatch { case _: DomElement => + } + + // Note: Full integration tests for click outside detection require a browser + // environment with real mouse events. The bindings are created correctly + // (tested above) and the handler code in DomRenderer will work in a real + // browser environment. + +end ClickOutsideTest diff --git a/uni-dom-test/src/test/scala/wvlet/uni/dom/TransitionTest.scala b/uni-dom-test/src/test/scala/wvlet/uni/dom/TransitionTest.scala new file mode 100644 index 0000000..c68f105 --- /dev/null +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/TransitionTest.scala @@ -0,0 +1,131 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import wvlet.uni.test.UniTest +import wvlet.uni.rx.Rx +import wvlet.uni.dom.all.* +import wvlet.uni.dom.all.given + +class TransitionTest extends UniTest: + + test("TransitionConfig has sensible defaults"): + val config = TransitionConfig() + config.name shouldBe "v" + config.duration shouldBe None + config.appear shouldBe false + + test("TransitionConfig can be customized"): + val config = TransitionConfig(name = "fade", duration = Some(300), appear = true) + config.name shouldBe "fade" + config.duration shouldBe Some(300) + config.appear shouldBe true + + test("TransitionConfig withName"): + val config = TransitionConfig().withName("slide") + config.name shouldBe "slide" + + test("TransitionConfig withDuration"): + val config = TransitionConfig().withDuration(500) + config.duration shouldBe Some(500) + + test("TransitionConfig noDuration"): + val config = TransitionConfig(duration = Some(300)).noDuration + config.duration shouldBe None + + test("TransitionConfig withAppear"): + val config = TransitionConfig().withAppear + config.appear shouldBe true + + test("Transition.apply with name returns RxElement"): + val visible = Rx.variable(false) + val transition = Transition("fade", visible)(div("content")) + transition shouldMatch { case _: RxElement => + } + + test("Transition.apply with config returns RxElement"): + val visible = Rx.variable(false) + val config = TransitionConfig(name = "slide", duration = Some(200)) + val transition = Transition(config, visible)(div("content")) + transition shouldMatch { case _: RxElement => + } + + test("Transition.fade returns RxElement"): + val visible = Rx.variable(false) + val transition = Transition.fade(visible)(div("content")) + transition shouldMatch { case _: RxElement => + } + + test("Transition.slide returns RxElement"): + val visible = Rx.variable(false) + val transition = Transition.slide(visible)(div("content")) + transition shouldMatch { case _: RxElement => + } + + test("Transition renders wrapper div with children"): + val visible = Rx.variable(true) + val transition = Transition("fade", visible)(span("hello")) + val (node, cancel) = DomRenderer.createNode(transition) + + node match + case elem: org.scalajs.dom.Element => + elem.tagName.toLowerCase shouldBe "div" + val spans = elem.querySelectorAll("span") + spans.length shouldBe 1 + case _ => + fail("Expected Element") + + cancel.cancel + + test("Transition renders hidden when initially false"): + val visible = Rx.variable(false) + val transition = Transition("fade", visible)(span("hello")) + val (node, cancel) = DomRenderer.createNode(transition) + + node match + case elem: org.scalajs.dom.HTMLElement => + elem.style.display shouldBe "none" + case _ => + fail("Expected HTMLElement") + + cancel.cancel + + test("Transition renders visible when initially true"): + val visible = Rx.variable(true) + val transition = Transition("fade", visible)(span("hello")) + val (node, cancel) = DomRenderer.createNode(transition) + + node match + case elem: org.scalajs.dom.HTMLElement => + elem.style.display shouldNotBe "none" + case _ => + fail("Expected HTMLElement") + + cancel.cancel + + test("Transition with multiple children"): + val visible = Rx.variable(true) + val transition = Transition("fade", visible)(span("a"), span("b"), span("c")) + val (node, cancel) = DomRenderer.createNode(transition) + + node match + case elem: org.scalajs.dom.Element => + val spans = elem.querySelectorAll("span") + spans.length shouldBe 3 + case _ => + fail("Expected Element") + + cancel.cancel + +end TransitionTest diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/ClickOutside.scala b/uni/.js/src/main/scala/wvlet/uni/dom/ClickOutside.scala new file mode 100644 index 0000000..c885fb5 --- /dev/null +++ b/uni/.js/src/main/scala/wvlet/uni/dom/ClickOutside.scala @@ -0,0 +1,88 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import org.scalajs.dom +import wvlet.uni.rx.{Cancelable, RxVar} + +import scala.scalajs.js + +/** + * Binding for click outside detection. Handled by DomRenderer to register a document-level + * mousedown listener that checks if the click target is outside the host element. + */ +case class ClickOutsideBinding(callback: dom.MouseEvent => Unit) extends DomNode + +/** + * Click outside detection for dismissing dropdowns, modals, and other overlays. + * + * Usage: + * {{{ + * import wvlet.uni.dom.all.* + * + * val isOpen = Rx.variable(true) + * + * // Dismiss dropdown on outside click + * isOpen.map { open => + * if open then + * div(cls -> "dropdown", + * ClickOutside.hide(isOpen), + * ul(li("Option 1"), li("Option 2")) + * ) + * else DomNode.empty + * } + * + * // Custom callback + * div( + * ClickOutside.detect { event => + * println(s"Clicked outside at (${event.clientX}, ${event.clientY})") + * }, + * "Click outside me" + * ) + * + * // Simple no-arg callback + * div( + * ClickOutside.onClickOutside(() => println("Outside!")), + * "Content" + * ) + * }}} + */ +object ClickOutside: + + /** + * Detect clicks outside the host element and invoke a callback with the mouse event. + * + * @param callback + * Function called with the MouseEvent when a click occurs outside + */ + def detect(callback: dom.MouseEvent => Unit): DomNode = ClickOutsideBinding(callback) + + /** + * Set an RxVar to false when a click occurs outside the host element. Convenient for hiding + * dropdowns and modals. + * + * @param visible + * The RxVar to set to false on outside click + */ + def hide(visible: RxVar[Boolean]): DomNode = ClickOutsideBinding(_ => visible := false) + + /** + * Invoke a no-arg callback when a click occurs outside the host element. + * + * @param callback + * Function called on outside click + */ + def onClickOutside(callback: () => Unit): DomNode = ClickOutsideBinding(_ => callback()) + +end ClickOutside diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala b/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala index b35408d..41c747b 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala @@ -262,6 +262,9 @@ object DomRenderer extends LogSupport: handleResizeEntryBinding(node, reb) case rbd: ResizeBindingDebounced => handleResizeBindingDebounced(node, rbd) + // Click outside detection + case cb: ClickOutsideBinding => + handleClickOutsideBinding(node, cb) // Ref bindings case rb: RefBinding[?] => node match @@ -776,6 +779,42 @@ object DomRenderer extends LogSupport: } end handleResizeBindingDebounced + /** + * Handle click outside detection by registering a document-level mousedown listener. + * + * Uses setTimeout(0) to avoid catching the opening click that triggered this binding. + */ + private def handleClickOutsideBinding(node: dom.Node, binding: ClickOutsideBinding): Cancelable = + val elem = node.asInstanceOf[dom.Element] + val callback = binding.callback + + var listener: js.UndefOr[js.Function1[dom.Event, Unit]] = js.undefined + + val setupId = dom + .window + .setTimeout( + () => + val handler: js.Function1[dom.Event, Unit] = + (event: dom.Event) => + event match + case me: dom.MouseEvent => + val target = me.target.asInstanceOf[dom.Node] + if !elem.contains(target) then + callback(me) + case _ => + () + listener = handler + dom.document.addEventListener("mousedown", handler) + , + 0 + ) + + Cancelable { () => + dom.window.clearTimeout(setupId) + listener.foreach(l => dom.document.removeEventListener("mousedown", l)) + } + end handleClickOutsideBinding + /** * Handle Portal rendering to a target element by ID. */ diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Transition.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Transition.scala new file mode 100644 index 0000000..97da66c --- /dev/null +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Transition.scala @@ -0,0 +1,289 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import org.scalajs.dom +import wvlet.uni.rx.{Cancelable, OnError, OnNext, Rx, RxRunner, RxVar} + +import scala.scalajs.js + +/** + * Configuration for CSS-based element transitions. + * + * @param name + * The CSS class prefix for transition classes (e.g., "fade" produces "fade-enter-from", + * "fade-enter-active", etc.) + * @param duration + * Optional explicit timeout in milliseconds. If set, the transition will end after this duration + * regardless of CSS transition/animation events. + * @param appear + * Whether to animate on initial render when visible is initially true + */ +case class TransitionConfig( + name: String = "v", + duration: Option[Int] = None, + appear: Boolean = false +): + def withName(name: String): TransitionConfig = copy(name = name) + def withDuration(duration: Int): TransitionConfig = copy(duration = Some(duration)) + def noDuration: TransitionConfig = copy(duration = None) + def withAppear: TransitionConfig = copy(appear = true) + +/** + * CSS-class-based transition system driven by `Rx[Boolean]` visibility. + * + * Manages enter/leave CSS class sequences on a wrapper `
` element, controlling visibility + * with `display: none`. The wrapper element always exists in the DOM. + * + * Usage: + * {{{ + * import wvlet.uni.dom.all.* + * + * val isOpen = Rx.variable(false) + * + * // Named transition (requires corresponding CSS classes) + * Transition("fade", isOpen)( + * div(cls -> "modal", "Hello") + * ) + * + * // Built-in fade + * Transition.fade(isOpen)(div("Fading content")) + * + * // Built-in slide + * Transition.slide(isOpen)(div("Sliding content")) + * + * // With config + * Transition(TransitionConfig(name = "fade", duration = Some(300), appear = true), isOpen)( + * div("Content") + * ) + * }}} + * + * CSS class sequence: + * - Enter: `{name}-enter-from` + `{name}-enter-active` -> next frame: remove `-enter-from`, add + * `-enter-to` -> on `transitionend`: remove `-enter-active` and `-enter-to` + * - Leave: `{name}-leave-from` + `{name}-leave-active` -> next frame: remove `-leave-from`, add + * `-leave-to` -> on `transitionend`: remove classes, set `display: none` + */ +object Transition: + + /** + * Create a transition with the given CSS class prefix. + * + * @param name + * CSS class prefix (e.g., "fade" produces "fade-enter-from", etc.) + * @param visible + * Reactive boolean controlling visibility + * @param children + * Child nodes to wrap + */ + def apply(name: String, visible: Rx[Boolean])(children: DomNode*): RxElement = TransitionElement( + TransitionConfig(name = name), + visible, + children + ) + + /** + * Create a transition with a full configuration. + * + * @param config + * Transition configuration + * @param visible + * Reactive boolean controlling visibility + * @param children + * Child nodes to wrap + */ + def apply(config: TransitionConfig, visible: Rx[Boolean])(children: DomNode*): RxElement = + TransitionElement(config, visible, children) + + /** + * Built-in fade transition. Requires CSS classes: `fade-enter-active`, `fade-leave-active` with + * `transition: opacity`, and `fade-enter-from`, `fade-leave-to` with `opacity: 0`. + */ + def fade(visible: Rx[Boolean])(children: DomNode*): RxElement = TransitionElement( + TransitionConfig(name = "fade"), + visible, + children + ) + + /** + * Built-in slide transition. Requires CSS classes: `slide-enter-active`, `slide-leave-active` + * with `transition: transform`, and `slide-enter-from`, `slide-leave-to` with `transform: + * translateY(-10px)` or similar. + */ + def slide(visible: Rx[Boolean])(children: DomNode*): RxElement = TransitionElement( + TransitionConfig(name = "slide"), + visible, + children + ) + +end Transition + +/** + * Internal: Manages CSS transition class sequences on a wrapper div element. + */ +private class TransitionElement( + config: TransitionConfig, + visible: Rx[Boolean], + children: Seq[DomNode] +) extends RxElement: + + private var containerRef: Option[dom.HTMLElement] = None + private var rxCancelable: Cancelable = Cancelable.empty + private var pendingTimeout: js.UndefOr[Int] = js.undefined + private var pendingRaf: js.UndefOr[Int] = js.undefined + private var transitionListener: js.UndefOr[js.Function1[dom.Event, Unit]] = js.undefined + private var isFirstRender: Boolean = true + + override def onMount(node: Any): Unit = + node match + case elem: dom.HTMLElement => + containerRef = Some(elem) + + // Set initial visibility + visible match + case rv: RxVar[Boolean @unchecked] => + if !rv.get then + elem.style.display = "none" + else if config.appear then + runEnter(elem) + case _ => + () + + // Subscribe to visibility changes + rxCancelable = + RxRunner.runContinuously(visible) { ev => + ev match + case OnNext(show: Boolean @unchecked) => + if isFirstRender then + isFirstRender = false + else + cancelPending() + if show then + runEnter(elem) + else + runLeave(elem) + case OnError(e) => + () + case _ => + () + } + case _ => + () + + override def beforeUnmount: Unit = + cancelPending() + rxCancelable.cancel + + private def cancelPending(): Unit = + pendingTimeout.foreach(id => dom.window.clearTimeout(id)) + pendingTimeout = js.undefined + pendingRaf.foreach(id => dom.window.cancelAnimationFrame(id)) + pendingRaf = js.undefined + containerRef.foreach { elem => + transitionListener.foreach { listener => + elem.removeEventListener("transitionend", listener) + elem.removeEventListener("animationend", listener) + } + } + transitionListener = js.undefined + + private def runEnter(elem: dom.HTMLElement): Unit = + val name = config.name + // Show element + elem.style.display = "" + // Apply enter-from and enter-active classes + elem.classList.add(s"${name}-enter-from") + elem.classList.add(s"${name}-enter-active") + + // Double rAF for frame-precise class changes + pendingRaf = dom + .window + .requestAnimationFrame { _ => + pendingRaf = dom + .window + .requestAnimationFrame { _ => + elem.classList.remove(s"${name}-enter-from") + elem.classList.add(s"${name}-enter-to") + + // Wait for transition end + waitForTransitionEnd(elem) { () => + elem.classList.remove(s"${name}-enter-active") + elem.classList.remove(s"${name}-enter-to") + } + } + } + + private def runLeave(elem: dom.HTMLElement): Unit = + val name = config.name + // Apply leave-from and leave-active classes + elem.classList.add(s"${name}-leave-from") + elem.classList.add(s"${name}-leave-active") + + // Double rAF for frame-precise class changes + pendingRaf = dom + .window + .requestAnimationFrame { _ => + pendingRaf = dom + .window + .requestAnimationFrame { _ => + elem.classList.remove(s"${name}-leave-from") + elem.classList.add(s"${name}-leave-to") + + // Wait for transition end + waitForTransitionEnd(elem) { () => + elem.classList.remove(s"${name}-leave-active") + elem.classList.remove(s"${name}-leave-to") + elem.style.display = "none" + } + } + } + + private def waitForTransitionEnd(elem: dom.HTMLElement)(onEnd: () => Unit): Unit = + val timeoutMs = config.duration.getOrElse(5000) + + val listener: js.Function1[dom.Event, Unit] = + (_: dom.Event) => + pendingTimeout.foreach(id => dom.window.clearTimeout(id)) + pendingTimeout = js.undefined + elem.removeEventListener("transitionend", transitionListener.get) + elem.removeEventListener("animationend", transitionListener.get) + transitionListener = js.undefined + onEnd() + transitionListener = listener + + elem.addEventListener("transitionend", listener) + elem.addEventListener("animationend", listener) + + // Safety timeout fallback + pendingTimeout = dom + .window + .setTimeout( + () => + transitionListener.foreach { l => + elem.removeEventListener("transitionend", l) + elem.removeEventListener("animationend", l) + } + transitionListener = js.undefined + onEnd() + , + timeoutMs + ) + + end waitForTransitionEnd + + override def render: RxElement = + import HtmlTags.tag + tag("div")(children*) + +end TransitionElement diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala index 69232b2..274a35f 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala @@ -141,6 +141,14 @@ object all extends HtmlTags with HtmlAttrs with SvgTags with SvgAttrs: export wvlet.uni.dom.FieldValidation export wvlet.uni.dom.FormValidation + // Transitions + export wvlet.uni.dom.Transition + export wvlet.uni.dom.TransitionConfig + + // Click outside detection + export wvlet.uni.dom.ClickOutside + export wvlet.uni.dom.ClickOutsideBinding + /** * Re-export helper functions. */