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 `