diff --git a/plans/2026-02-04-keyboard-focus.md b/plans/2026-02-04-keyboard-focus.md new file mode 100644 index 00000000..c526b144 --- /dev/null +++ b/plans/2026-02-04-keyboard-focus.md @@ -0,0 +1,252 @@ +# Keyboard Shortcuts & Focus Management Plan + +## Overview + +Add two accessibility-focused features to uni-dom: +1. **Keyboard Shortcuts** - Global and scoped keyboard shortcut management +2. **Focus Management** - Focus traps, auto-focus, and focus restoration + +## API Design + +### 1. Keyboard Shortcuts + +```scala +object Keyboard: + // Global shortcuts + def bind(shortcut: String, handler: () => Unit): Cancelable + def bindAll(bindings: (String, () => Unit)*): Cancelable + + // Scoped shortcuts (element modifier) + def scoped(bindings: (String, () => Unit)*): DomNode + + // Reactive key state + def isPressed(key: String): Rx[Boolean] + def modifiers: Rx[Modifiers] + +case class Modifiers( + ctrl: Boolean, + alt: Boolean, + shift: Boolean, + meta: Boolean +) +``` + +**Shortcut String Format:** +- `"escape"` - Single key +- `"ctrl+s"` - Modifier + key +- `"ctrl+shift+p"` - Multiple modifiers +- `"meta+k"` - Command key (Mac) / Windows key + +**Usage:** +```scala +// Global shortcuts +val cancel = Keyboard.bind("ctrl+s", () => save()) +val cancel2 = Keyboard.bindAll( + "ctrl+s" -> (() => save()), + "escape" -> (() => closeModal()) +) + +// Scoped shortcuts (only when element focused) +input( + Keyboard.scoped( + "enter" -> (() => submit()), + "escape" -> (() => cancel()) + ) +) + +// Reactive state +Keyboard.isPressed("shift").map { pressed => + if pressed then "Multi-select mode" else "Single select" +} +``` + +### 2. Focus Management + +```scala +object Focus: + // Focus trap - tab cycles within children + def trap(children: DomNode*): RxElement + + // Auto-focus on mount + def onMount: DomNode + def onMountDelayed(delayMs: Int = 0): DomNode + + // Focus restoration + def withRestoration[A](f: (() => Unit) => A): A + + // Utilities + def active: Rx[Option[dom.Element]] + def focusById(id: String): Unit + def blur(): Unit +``` + +**Usage:** +```scala +// Modal with focus trap +val isOpen = Rx.variable(false) +isOpen.map { open => + if open then + Focus.trap( + div(cls -> "modal", + input(Focus.onMount, placeholder -> "Email"), + button("Submit"), + button(onclick -> (() => isOpen := false), "Close") + ) + ) + else DomNode.empty +} + +// Focus restoration +Focus.withRestoration { restoreFocus => + showModal() + button(onclick -> { () => + closeModal() + restoreFocus() + }, "Close") +} +``` + +## Implementation Details + +### Keyboard: Global Listener Pattern + +Follow `WindowScroll.scala` pattern: + +```scala +private class KeyboardState extends Cancelable: + private val pressedKeys = Rx.variable(Set.empty[String]) + private val bindings = mutable.Map[String, () => Unit]() + + private val keydownHandler: js.Function1[dom.KeyboardEvent, Unit] = e => + val combo = buildCombo(e) + pressedKeys.update(_ + e.key.toLowerCase) + bindings.get(combo).foreach { handler => + e.preventDefault() + handler() + } + + private val keyupHandler: js.Function1[dom.KeyboardEvent, Unit] = e => + pressedKeys.update(_ - e.key.toLowerCase) + + dom.window.addEventListener("keydown", keydownHandler) + dom.window.addEventListener("keyup", keyupHandler) + + override def cancel: Unit = + dom.window.removeEventListener("keydown", keydownHandler) + dom.window.removeEventListener("keyup", keyupHandler) +``` + +### Keyboard: Shortcut Parsing + +```scala +case class KeyCombination( + key: String, // "s", "escape", "enter" + ctrl: Boolean = false, + alt: Boolean = false, + shift: Boolean = false, + meta: Boolean = false +) + +def parse(shortcut: String): KeyCombination = + val parts = shortcut.toLowerCase.split("\\+") + KeyCombination( + key = parts.last, + ctrl = parts.contains("ctrl"), + alt = parts.contains("alt"), + shift = parts.contains("shift"), + meta = parts.contains("meta") || parts.contains("cmd") + ) +``` + +### Focus: Trap Implementation + +```scala +private case class FocusTrap(children: Seq[DomNode]) extends RxElement: + private var firstFocusable: Option[dom.Element] = None + private var lastFocusable: Option[dom.Element] = None + + override def onMount(node: Any): Unit = + val container = node.asInstanceOf[dom.Element] + val focusables = getFocusableElements(container) + firstFocusable = focusables.headOption + lastFocusable = focusables.lastOption + firstFocusable.foreach(_.asInstanceOf[dom.HTMLElement].focus()) + + override def render: RxElement = + div( + tabindex -> "-1", + onkeydown -> { (e: dom.KeyboardEvent) => + if e.key == "Tab" then + if e.shiftKey then + // Shift+Tab: wrap to last if at first + if dom.document.activeElement == firstFocusable.orNull then + e.preventDefault() + lastFocusable.foreach(_.asInstanceOf[dom.HTMLElement].focus()) + else + // Tab: wrap to first if at last + if dom.document.activeElement == lastFocusable.orNull then + e.preventDefault() + firstFocusable.foreach(_.asInstanceOf[dom.HTMLElement].focus()) + }, + children* + ) + +def getFocusableElements(container: dom.Element): Seq[dom.Element] = + val selector = "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])" + container.querySelectorAll(selector).toSeq +``` + +### Focus: onMount Modifier + +```scala +case class FocusOnMount(delay: Int = 0) extends RxElement: + override def onMount(node: Any): Unit = + node match + case elem: dom.HTMLElement => + if delay > 0 then + dom.window.setTimeout(() => elem.focus(), delay) + else + elem.focus() + case _ => () + + override def render: RxElement = DomNode.empty +``` + +## Files to Create + +| File | Description | +|------|-------------| +| `uni/.js/src/main/scala/wvlet/uni/dom/Keyboard.scala` | Keyboard shortcuts implementation | +| `uni/.js/src/main/scala/wvlet/uni/dom/Focus.scala` | Focus management implementation | +| `uni-dom-test/src/test/scala/wvlet/uni/dom/KeyboardTest.scala` | Keyboard tests | +| `uni-dom-test/src/test/scala/wvlet/uni/dom/FocusTest.scala` | Focus tests | + +## Files to Modify + +| File | Change | +|------|--------| +| `uni/.js/src/main/scala/wvlet/uni/dom/all.scala` | Export Keyboard, Focus, Modifiers | + +## Patterns to Follow + +- `WindowScroll.scala` - Global listener with cleanup +- `NetworkStatus.scala` - Simple reactive state +- `DomRef.scala` - Focus/blur methods already exist +- `RxElement.scala` - Lifecycle hooks (onMount, beforeUnmount) + +## Verification + +```bash +./sbt "uniJS/compile" +./sbt "domTest/testOnly *KeyboardTest" +./sbt "domTest/testOnly *FocusTest" +./sbt scalafmtAll +``` + +## Edge Cases + +1. **Multiple bindings for same shortcut** - Last one wins +2. **Scoped shortcuts with focus** - Only fire when element has focus +3. **Focus trap with no focusable elements** - Container itself gets focus +4. **Browser default shortcuts** - `preventDefault()` to override (e.g., Ctrl+S) +5. **Mac vs Windows** - `meta` = Cmd on Mac, Windows key on Windows diff --git a/uni-dom-test/src/test/scala/wvlet/uni/dom/FocusTest.scala b/uni-dom-test/src/test/scala/wvlet/uni/dom/FocusTest.scala new file mode 100644 index 00000000..09feaf7c --- /dev/null +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/FocusTest.scala @@ -0,0 +1,196 @@ +/* + * 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.test.UniTest +import wvlet.uni.dom.all.* +import wvlet.uni.dom.all.given +import wvlet.uni.rx.Rx + +class FocusTest extends UniTest: + + test("Focus.trap returns RxElement"): + val trap = Focus.trap(div("content")) + trap shouldMatch { case _: RxElement => + } + + test("Focus.onMount returns DomNode"): + val node = Focus.onMount + node shouldMatch { case _: DomNode => + } + + test("Focus.onMountDelayed returns DomNode"): + val node = Focus.onMountDelayed(100) + node shouldMatch { case _: DomNode => + } + + test("Focus.active returns Rx[Option[Element]]"): + val active = Focus.active + active shouldMatch { case _: Rx[?] => + } + + test("Focus.currentActive returns Option[Element]"): + val active = Focus.currentActive + active shouldMatch { case _: Option[?] => + } + + test("Focus.saveAndRestore returns restore function"): + val restore = Focus.saveAndRestore() + restore shouldMatch { case _: Function0[?] => + } + + test("Focus.withRestoration executes function with restore callback"): + var restoreCalled = false + val result = Focus.withRestoration { restore => + // Verify restore is a function + restore shouldMatch { case _: Function0[?] => + } + "result" + } + result shouldBe "result" + + test("Focus.focusById focuses element by id"): + // Create a test element + val testDiv = dom.document.createElement("div") + testDiv.id = "focus-test-element" + testDiv.setAttribute("tabindex", "-1") + dom.document.body.appendChild(testDiv) + + try + Focus.focusById("focus-test-element") + // Check it was focused + dom.document.activeElement shouldBe testDiv + finally + dom.document.body.removeChild(testDiv) + + test("Focus.blur removes focus"): + val testInput = dom.document.createElement("input").asInstanceOf[dom.HTMLInputElement] + dom.document.body.appendChild(testInput) + + try + testInput.focus() + dom.document.activeElement shouldBe testInput + + Focus.blur() + // After blur, focus should no longer be on the input + (dom.document.activeElement == testInput) shouldBe false + // And activeElement should be body + dom.document.activeElement shouldBe dom.document.body + finally + dom.document.body.removeChild(testInput) + + test("Focus.getFocusableElements finds buttons"): + val container = dom.document.createElement("div") + val button = dom.document.createElement("button") + container.appendChild(button) + dom.document.body.appendChild(container) + + try + val focusables = Focus.getFocusableElements(container) + focusables.length shouldBe 1 + focusables.head shouldBe button + finally + dom.document.body.removeChild(container) + + test("Focus.getFocusableElements finds inputs"): + val container = dom.document.createElement("div") + val input = dom.document.createElement("input") + container.appendChild(input) + dom.document.body.appendChild(container) + + try + val focusables = Focus.getFocusableElements(container) + focusables.length shouldBe 1 + focusables.head shouldBe input + finally + dom.document.body.removeChild(container) + + test("Focus.getFocusableElements excludes disabled elements"): + val container = dom.document.createElement("div") + val button = dom.document.createElement("button") + button.setAttribute("disabled", "true") + container.appendChild(button) + dom.document.body.appendChild(container) + + try + val focusables = Focus.getFocusableElements(container) + focusables.length shouldBe 0 + finally + dom.document.body.removeChild(container) + + test("Focus.getFocusableElements finds elements with tabindex"): + val container = dom.document.createElement("div") + val divTab = dom.document.createElement("div") + divTab.setAttribute("tabindex", "0") + container.appendChild(divTab) + dom.document.body.appendChild(container) + + try + val focusables = Focus.getFocusableElements(container) + focusables.length shouldBe 1 + focusables.head shouldBe divTab + finally + dom.document.body.removeChild(container) + + test("Focus.getFocusableElements excludes tabindex=-1"): + val container = dom.document.createElement("div") + val divTab = dom.document.createElement("div") + divTab.setAttribute("tabindex", "-1") + container.appendChild(divTab) + dom.document.body.appendChild(container) + + try + val focusables = Focus.getFocusableElements(container) + focusables.length shouldBe 0 + finally + dom.document.body.removeChild(container) + + test("Focus.active emits values reactively"): + var result: Option[dom.Element] = None + val cancel = Focus + .active + .run { v => + result = v + } + result shouldMatch { case _: Option[?] => + } + cancel.cancel + + test("Focus.trap renders with tabindex"): + val trap = Focus.trap(button("Click me")) + val (node, cancel) = DomRenderer.createNode(trap) + + node match + case elem: dom.Element => + elem.getAttribute("tabindex") shouldBe "-1" + case _ => + fail("Expected Element") + + cancel.cancel + + test("Focus.trap contains children"): + val trap = Focus.trap(button("Button 1"), button("Button 2")) + val (node, cancel) = DomRenderer.createNode(trap) + + node match + case elem: dom.Element => + val buttons = elem.querySelectorAll("button") + buttons.length shouldBe 2 + case _ => + fail("Expected Element") + + cancel.cancel + +end FocusTest diff --git a/uni-dom-test/src/test/scala/wvlet/uni/dom/KeyboardTest.scala b/uni-dom-test/src/test/scala/wvlet/uni/dom/KeyboardTest.scala new file mode 100644 index 00000000..3f6064b5 --- /dev/null +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/KeyboardTest.scala @@ -0,0 +1,147 @@ +/* + * 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.dom.all.* +import wvlet.uni.dom.all.given +import wvlet.uni.rx.Rx + +class KeyboardTest extends UniTest: + + test("KeyCombination.parse parses single key"): + val combo = KeyCombination.parse("escape") + combo.key shouldBe "escape" + combo.ctrl shouldBe false + combo.alt shouldBe false + combo.shift shouldBe false + combo.meta shouldBe false + + test("KeyCombination.parse parses ctrl+key"): + val combo = KeyCombination.parse("ctrl+s") + combo.key shouldBe "s" + combo.ctrl shouldBe true + combo.alt shouldBe false + combo.shift shouldBe false + combo.meta shouldBe false + + test("KeyCombination.parse parses multiple modifiers"): + val combo = KeyCombination.parse("ctrl+shift+p") + combo.key shouldBe "p" + combo.ctrl shouldBe true + combo.alt shouldBe false + combo.shift shouldBe true + combo.meta shouldBe false + + test("KeyCombination.parse parses all modifiers"): + val combo = KeyCombination.parse("ctrl+alt+shift+meta+k") + combo.key shouldBe "k" + combo.ctrl shouldBe true + combo.alt shouldBe true + combo.shift shouldBe true + combo.meta shouldBe true + + test("KeyCombination.parse parses cmd as meta"): + val combo = KeyCombination.parse("cmd+k") + combo.key shouldBe "k" + combo.meta shouldBe true + + test("KeyCombination.parse is case-insensitive"): + val combo = KeyCombination.parse("CTRL+S") + combo.key shouldBe "s" + combo.ctrl shouldBe true + + test("KeyCombination.toShortcutString produces normalized string"): + val combo = KeyCombination(key = "s", ctrl = true, shift = true) + combo.toShortcutString shouldBe "ctrl+shift+s" + + test("KeyCombination.toShortcutString handles single key"): + val combo = KeyCombination(key = "escape") + combo.toShortcutString shouldBe "escape" + + test("Modifiers.none has all modifiers false"): + val mods = Modifiers.none + mods.ctrl shouldBe false + mods.alt shouldBe false + mods.shift shouldBe false + mods.meta shouldBe false + + test("Keyboard.bind returns Cancelable"): + var called = false + val cancel = Keyboard.bind("ctrl+t", () => called = true) + cancel shouldMatch { case _: wvlet.uni.rx.Cancelable => + } + cancel.cancel + + test("Keyboard.bindAll returns Cancelable"): + val cancel = Keyboard.bindAll("ctrl+s" -> (() => ()), "escape" -> (() => ())) + cancel shouldMatch { case _: wvlet.uni.rx.Cancelable => + } + cancel.cancel + + test("Keyboard.scoped returns DomNode"): + val node = Keyboard.scoped("enter" -> (() => ())) + node shouldMatch { case _: DomNode => + } + + test("Keyboard.isPressed returns Rx[Boolean]"): + val pressed = Keyboard.isPressed("shift") + pressed shouldMatch { case _: Rx[?] => + } + + test("Keyboard.modifiers returns Rx[Modifiers]"): + val mods = Keyboard.modifiers + mods shouldMatch { case _: Rx[?] => + } + + test("Keyboard.currentModifiers returns Modifiers"): + val mods = Keyboard.currentModifiers + mods shouldMatch { case Modifiers(_, _, _, _) => + } + + test("Keyboard.isPressedNow returns Boolean"): + val pressed = Keyboard.isPressedNow("shift") + pressed shouldMatch { case _: Boolean => + } + + test("Keyboard.isPressed emits values reactively"): + var result = false + val cancel = Keyboard + .isPressed("shift") + .run { v => + result = v + } + // Initially no keys pressed + result shouldBe false + cancel.cancel + + test("Keyboard.modifiers emits values reactively"): + var result: Modifiers = Modifiers.none + val cancel = Keyboard + .modifiers + .run { v => + result = v + } + result shouldMatch { case Modifiers(_, _, _, _) => + } + cancel.cancel + + test("KeyCombination.matches checks key and modifiers"): + val combo = KeyCombination(key = "s", ctrl = true) + // We can't easily create KeyboardEvents in jsdom, but we can test the logic + combo.key shouldBe "s" + combo.ctrl shouldBe true + combo.alt shouldBe false + +end KeyboardTest diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Focus.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Focus.scala new file mode 100644 index 00000000..9a715aa1 --- /dev/null +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Focus.scala @@ -0,0 +1,300 @@ +/* + * 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, Rx, RxVar} + +import scala.scalajs.js + +/** + * Focus management utilities for accessibility. + * + * Usage: + * {{{ + * import wvlet.uni.dom.all.* + * + * // Focus trap for modals (tab cycles within children) + * val isOpen = Rx.variable(false) + * isOpen.map { open => + * if open then + * Focus.trap( + * div(cls -> "modal", + * input(Focus.onMount, placeholder -> "Email"), + * button("Submit"), + * button(onclick -> (() => isOpen := false), "Close") + * ) + * ) + * else DomNode.empty + * } + * + * // Auto-focus on mount + * input(Focus.onMount, placeholder -> "Auto-focused") + * + * // Focus with delay (useful for animations) + * input(Focus.onMountDelayed(100), placeholder -> "Focused after 100ms") + * + * // Track active element + * Focus.active.map { elem => + * elem.map(_.tagName).getOrElse("none") + * } + * }}} + */ +object Focus: + private class FocusState extends Cancelable: + private val activeElementVar: RxVar[Option[dom.Element]] = Rx.variable( + Option(dom.document.activeElement) + ) + + private val focusHandler: js.Function1[dom.FocusEvent, Unit] = + _ => activeElementVar := Option(dom.document.activeElement) + + private val blurHandler: js.Function1[dom.FocusEvent, Unit] = + _ => + // Delay to allow activeElement to update + dom.window.setTimeout(() => activeElementVar := Option(dom.document.activeElement), 0) + + dom.document.addEventListener("focusin", focusHandler) + dom.document.addEventListener("focusout", blurHandler) + + def active: Rx[Option[dom.Element]] = activeElementVar + def currentActive: Option[dom.Element] = activeElementVar.get + + override def cancel: Unit = + dom.document.removeEventListener("focusin", focusHandler) + dom.document.removeEventListener("focusout", blurHandler) + + end FocusState + + private lazy val instance: FocusState = FocusState() + + /** + * Create a focus trap that keeps focus within the children. Useful for modals and dialogs. + * + * Tab cycles through focusable elements, wrapping from last to first. Shift+Tab wraps from first + * to last. The first focusable element is automatically focused on mount. + * + * @param children + * Content to trap focus within + * @return + * RxElement that manages focus trapping + */ + def trap(children: DomNode*): RxElement = FocusTrap(children) + + /** + * Auto-focus this element when it mounts. + * + * @return + * DomNode modifier + */ + def onMount: DomNode = FocusOnMount(delay = 0) + + /** + * Auto-focus this element after a delay when it mounts. Useful for elements that appear with + * animations. + * + * @param delayMs + * Delay in milliseconds before focusing + * @return + * DomNode modifier + */ + def onMountDelayed(delayMs: Int): DomNode = FocusOnMount(delay = delayMs) + + /** + * Execute a function with focus restoration. When the returned restore function is called, focus + * returns to the element that was focused before. + * + * @param f + * Function that receives a restore callback + * @return + * Result of the function + */ + def withRestoration[A](f: (() => Unit) => A): A = + val previousElement = dom.document.activeElement + val restore: () => Unit = + () => + previousElement match + case elem: dom.HTMLElement => + elem.focus() + case _ => + () + f(restore) + + /** + * Save the currently focused element and return a function to restore focus to it. + * + * @return + * Function to restore focus + */ + def saveAndRestore(): () => Unit = + val previousElement = dom.document.activeElement + () => + previousElement match + case elem: dom.HTMLElement => + elem.focus() + case _ => + () + + /** + * Reactive stream of the currently focused element. + */ + def active: Rx[Option[dom.Element]] = instance.active + + /** + * Get the currently focused element synchronously. + */ + def currentActive: Option[dom.Element] = instance.currentActive + + /** + * Focus an element by its ID. + * + * @param id + * Element ID + */ + def focusById(id: String): Unit = Option(dom.document.getElementById(id)).foreach { + case elem: dom.HTMLElement => + elem.focus() + case _ => + () + } + + /** + * Remove focus from the currently focused element. + */ + def blur(): Unit = + dom.document.activeElement match + case elem: dom.HTMLElement => + elem.blur() + case _ => + () + + /** + * Get all focusable elements within a container. + * + * @param container + * Container element + * @return + * Sequence of focusable elements + */ + def getFocusableElements(container: dom.Element): Seq[dom.Element] = + val selector = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), " + + "textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"]):not([disabled])" + val nodeList = container.querySelectorAll(selector) + (0 until nodeList.length) + .map(nodeList(_)) + .filter { elem => + // Filter out invisible elements (display: none or visibility: hidden) + elem match + case htmlElem: dom.HTMLElement => + val style = dom.window.getComputedStyle(htmlElem) + style.display != "none" && style.visibility != "hidden" + case _ => + // For non-HTMLElements (like SVG), assume they are visible if selected + true + } + + /** + * Stop focus tracking. Call this when the application is shutting down. + */ + def stop(): Unit = instance.cancel + +end Focus + +/** + * Internal: Focus trap element that keeps tab navigation within its children. + */ +private case class FocusTrap(children: Seq[DomNode]) extends RxElement: + private var containerRef: Option[dom.Element] = None + private var firstFocusable: Option[dom.Element] = None + private var lastFocusable: Option[dom.Element] = None + + override def onMount(node: Any): Unit = + node match + case container: dom.Element => + containerRef = Some(container) + updateFocusables() + // Focus the first focusable element, or the container itself if none + firstFocusable match + case Some(elem: dom.HTMLElement) => + elem.focus() + case _ => + // No focusable children, focus the container to activate the trap + container match + case c: dom.HTMLElement => + c.focus() + case _ => + () + case _ => + () + + private def updateFocusables(): Unit = containerRef.foreach { container => + val focusables = Focus.getFocusableElements(container) + firstFocusable = focusables.headOption + lastFocusable = focusables.lastOption + } + + override def render: RxElement = + import HtmlTags.{tag, attr, handler} + val onkeydown = handler[dom.KeyboardEvent]("onkeydown") + val keyHandler = onkeydown { (e: dom.KeyboardEvent) => + if e.key == "Tab" then + // Refresh focusables in case content changed + updateFocusables() + + val activeElement = dom.document.activeElement + if e.shiftKey then + // Shift+Tab: wrap to last if at first + if firstFocusable.contains(activeElement) then + e.preventDefault() + lastFocusable.foreach { + case elem: dom.HTMLElement => + elem.focus() + case _ => + () + } + else + // Tab: wrap to first if at last + if lastFocusable.contains(activeElement) then + e.preventDefault() + firstFocusable.foreach { + case elem: dom.HTMLElement => + elem.focus() + case _ => + () + } + } + tag("div")((Seq(attr("tabindex")("-1"), keyHandler) ++ children)*) + + end render + +end FocusTrap + +/** + * Internal: Auto-focus element on mount. + */ +private case class FocusOnMount(delay: Int) extends RxElement: + override def onMount(node: Any): Unit = + node match + case elem: dom.HTMLElement => + if delay > 0 then + dom.window.setTimeout(() => elem.focus(), delay) + else + elem.focus() + case _ => + () + + override def render: RxElement = RxElement.empty + +end FocusOnMount diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Keyboard.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Keyboard.scala new file mode 100644 index 00000000..8b60d7eb --- /dev/null +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Keyboard.scala @@ -0,0 +1,278 @@ +/* + * 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, Rx, RxVar} + +import scala.collection.mutable +import scala.scalajs.js + +/** + * Current state of modifier keys. + */ +case class Modifiers(ctrl: Boolean, alt: Boolean, shift: Boolean, meta: Boolean) + +object Modifiers: + val none: Modifiers = Modifiers(ctrl = false, alt = false, shift = false, meta = false) + +/** + * Parsed keyboard shortcut combination. + * + * @param key + * The main key (e.g., "s", "escape", "enter") + * @param ctrl + * Whether Ctrl is required + * @param alt + * Whether Alt is required + * @param shift + * Whether Shift is required + * @param meta + * Whether Meta (Cmd on Mac, Windows key) is required + */ +case class KeyCombination( + key: String, + ctrl: Boolean = false, + alt: Boolean = false, + shift: Boolean = false, + meta: Boolean = false +): + /** + * Check if this combination matches a keyboard event. + */ + def matches(e: dom.KeyboardEvent): Boolean = + e.key.toLowerCase == key && e.ctrlKey == ctrl && e.altKey == alt && e.shiftKey == shift && + e.metaKey == meta + + /** + * String representation of this combination. + */ + def toShortcutString: String = + val parts = mutable.ArrayBuffer[String]() + if ctrl then + parts += "ctrl" + if alt then + parts += "alt" + if shift then + parts += "shift" + if meta then + parts += "meta" + parts += key + parts.mkString("+") + +end KeyCombination + +object KeyCombination: + /** + * Parse a shortcut string into a KeyCombination. + * + * Supported formats: + * - "escape" - Single key + * - "ctrl+s" - Modifier + key + * - "ctrl+shift+p" - Multiple modifiers + * - "meta+k" or "cmd+k" - Command key + */ + def parse(shortcut: String): KeyCombination = + val parts = shortcut.toLowerCase.split("\\+").map(_.trim) + KeyCombination( + key = parts.last, + ctrl = parts.contains("ctrl"), + alt = parts.contains("alt"), + shift = parts.contains("shift"), + meta = parts.contains("meta") || parts.contains("cmd") + ) + +end KeyCombination + +/** + * Global keyboard shortcut management. + * + * Usage: + * {{{ + * import wvlet.uni.dom.all.* + * + * // Bind a global shortcut + * val cancel = Keyboard.bind("ctrl+s", () => save()) + * + * // Bind multiple shortcuts + * val cancel = Keyboard.bindAll( + * "ctrl+s" -> (() => save()), + * "escape" -> (() => closeModal()), + * "ctrl+shift+p" -> (() => openCommandPalette()) + * ) + * + * // Scoped shortcuts (only when element focused) + * input( + * Keyboard.scoped( + * "enter" -> (() => submit()), + * "escape" -> (() => cancel()) + * ) + * ) + * + * // Track key state reactively + * Keyboard.isPressed("shift").map { pressed => + * if pressed then "Multi-select mode" else "Single select" + * } + * + * // Don't forget to cancel when done + * cancel.cancel + * }}} + */ +object Keyboard: + private class KeyboardState extends Cancelable: + private val pressedKeysVar: RxVar[Set[String]] = Rx.variable(Set.empty[String]) + private val modifiersVar: RxVar[Modifiers] = Rx.variable(Modifiers.none) + private val bindings: mutable.Map[String, mutable.ArrayBuffer[() => Unit]] = mutable.Map.empty + + private val keydownHandler: js.Function1[dom.KeyboardEvent, Unit] = + (e: dom.KeyboardEvent) => + val key = e.key.toLowerCase + pressedKeysVar.update(_ + key) + modifiersVar := + Modifiers(ctrl = e.ctrlKey, alt = e.altKey, shift = e.shiftKey, meta = e.metaKey) + + // Check for matching shortcuts + val combo = KeyCombination( + key = key, + ctrl = e.ctrlKey, + alt = e.altKey, + shift = e.shiftKey, + meta = e.metaKey + ) + val comboStr = combo.toShortcutString + bindings + .get(comboStr) + .foreach { handlers => + if handlers.nonEmpty then + e.preventDefault() + handlers.foreach(_()) + } + + private val keyupHandler: js.Function1[dom.KeyboardEvent, Unit] = + (e: dom.KeyboardEvent) => + pressedKeysVar.update(_ - e.key.toLowerCase) + modifiersVar := + Modifiers(ctrl = e.ctrlKey, alt = e.altKey, shift = e.shiftKey, meta = e.metaKey) + + dom.window.addEventListener("keydown", keydownHandler) + dom.window.addEventListener("keyup", keyupHandler) + + def addBinding(shortcut: String, handler: () => Unit): Cancelable = + val combo = KeyCombination.parse(shortcut) + val key = combo.toShortcutString + val handlers = bindings.getOrElseUpdate(key, mutable.ArrayBuffer.empty) + handlers += handler + Cancelable { () => + handlers -= handler + if handlers.isEmpty then + bindings.remove(key) + } + + def pressedKeys: Set[String] = pressedKeysVar.get + def rxPressedKeys: Rx[Set[String]] = pressedKeysVar + def currentModifiers: Modifiers = modifiersVar.get + def rxModifiers: Rx[Modifiers] = modifiersVar + + override def cancel: Unit = + dom.window.removeEventListener("keydown", keydownHandler) + dom.window.removeEventListener("keyup", keyupHandler) + bindings.clear() + + end KeyboardState + + private lazy val instance: KeyboardState = KeyboardState() + + /** + * Bind a global keyboard shortcut. + * + * @param shortcut + * Shortcut string (e.g., "ctrl+s", "escape", "ctrl+shift+p") + * @param handler + * Handler to execute when shortcut is pressed + * @return + * Cancelable to remove the binding + */ + def bind(shortcut: String, handler: () => Unit): Cancelable = instance.addBinding( + shortcut, + handler + ) + + /** + * Bind multiple global keyboard shortcuts. + * + * @param bindings + * Pairs of shortcut strings and handlers + * @return + * Cancelable to remove all bindings + */ + def bindAll(bindings: (String, () => Unit)*): Cancelable = + val cancelables = bindings.map { case (shortcut, handler) => + bind(shortcut, handler) + } + Cancelable.merge(cancelables) + + /** + * Create a scoped keyboard shortcut modifier that only fires when the element has focus. + * + * @param bindings + * Pairs of shortcut strings and handlers + * @return + * DomNode modifier to add to an element + */ + def scoped(bindings: (String, () => Unit)*): DomNode = + val parsedBindings = bindings.map { case (shortcut, handler) => + KeyCombination.parse(shortcut) -> handler + } + val onkeydown = HtmlTags.handler[dom.KeyboardEvent]("onkeydown") + onkeydown { (e: dom.KeyboardEvent) => + parsedBindings + .find(_._1.matches(e)) + .foreach { case (_, handler) => + e.preventDefault() + e.stopPropagation() + handler() + } + } + + /** + * Check if a specific key is currently pressed. + * + * @param key + * Key name (e.g., "shift", "a", "escape") + * @return + * Reactive boolean indicating if the key is pressed + */ + def isPressed(key: String): Rx[Boolean] = instance.rxPressedKeys.map(_.contains(key.toLowerCase)) + + /** + * Get the current state of modifier keys. + */ + def modifiers: Rx[Modifiers] = instance.rxModifiers + + /** + * Get the current modifier state synchronously. + */ + def currentModifiers: Modifiers = instance.currentModifiers + + /** + * Check if a key is currently pressed (synchronous). + */ + def isPressedNow(key: String): Boolean = instance.pressedKeys.contains(key.toLowerCase) + + /** + * Stop all keyboard listeners. Call this when the application is shutting down. + */ + def stop(): Unit = instance.cancel + +end Keyboard diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/RxElement.scala b/uni/.js/src/main/scala/wvlet/uni/dom/RxElement.scala index c03d70f1..0dd1e1e7 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/RxElement.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/RxElement.scala @@ -107,6 +107,14 @@ object RxElement: */ private[dom] val NoOp: Any => Unit = (_: Any) => () + /** + * An empty RxElement that renders nothing. + */ + val empty: RxElement = + new RxElement(): + // Return Embedded(DomNode.empty) to terminate recursion in DomRenderer + override def render: RxElement = Embedded(DomNode.empty) + /** * Create an RxElement from an existing element. */ 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 970ebed3..a7883ead 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala @@ -112,6 +112,14 @@ object all extends HtmlTags with HtmlAttrs with SvgTags with SvgAttrs: export wvlet.uni.dom.RouteParams export wvlet.uni.dom.Location + // Keyboard shortcuts + export wvlet.uni.dom.Keyboard + export wvlet.uni.dom.KeyCombination + export wvlet.uni.dom.Modifiers + + // Focus management + export wvlet.uni.dom.Focus + /** * Re-export helper functions. */