commit 3cd170ae4c8b6474f46315d4ea21d1e59123b832
parent 7cc017b180d284bb4330177d83f9d08baf208a64
Author: triesap <triesap@radroots.dev>
Date: Wed, 21 Jan 2026 20:14:31 +0000
ui: add focus scope primitive
- add focus scope component with trap and restore hooks
- add focus selector and wrap helpers
- add unit tests for selector and index wrapping
- export focus scope helpers from ui-primitives
Diffstat:
3 files changed, 215 insertions(+), 0 deletions(-)
diff --git a/crates/ui-primitives/Cargo.toml b/crates/ui-primitives/Cargo.toml
@@ -19,8 +19,10 @@ web-sys = { workspace = true, features = [
"Element",
"EventTarget",
"FocusEvent",
+ "HtmlElement",
"KeyboardEvent",
"Node",
+ "NodeList",
"PointerEvent",
"Window"
] }
diff --git a/crates/ui-primitives/src/focus.rs b/crates/ui-primitives/src/focus.rs
@@ -0,0 +1,207 @@
+use leptos::ev::KeyboardEvent;
+use leptos::html;
+use leptos::prelude::*;
+
+const RADROOTS_APP_UI_FOCUSABLE_SELECTOR: &str =
+ "a[href],button,textarea,input,select,[tabindex]:not([tabindex='-1'])";
+
+pub fn radroots_app_ui_focus_scope_selector() -> &'static str {
+ RADROOTS_APP_UI_FOCUSABLE_SELECTOR
+}
+
+pub fn radroots_app_ui_focus_scope_next_index(
+ current: usize,
+ count: usize,
+ shift: bool,
+) -> usize {
+ if count == 0 {
+ return 0;
+ }
+ if shift {
+ if current == 0 {
+ count - 1
+ } else {
+ current - 1
+ }
+ } else if current + 1 >= count {
+ 0
+ } else {
+ current + 1
+ }
+}
+
+#[component]
+pub fn RadrootsAppUiFocusScope(
+ #[prop(optional)] trapped: bool,
+ #[prop(optional)] auto_focus: bool,
+ #[prop(optional)] return_focus: bool,
+ #[prop(optional)] on_mount_auto_focus: Option<Callback<()>>,
+ #[prop(optional)] on_unmount_auto_focus: Option<Callback<()>>,
+ children: ChildrenFn,
+) -> impl IntoView {
+ let node_ref = NodeRef::<html::Div>::new();
+
+ #[cfg(target_arch = "wasm32")]
+ {
+ use wasm_bindgen::closure::Closure;
+ use wasm_bindgen::JsCast;
+
+ let on_mount_auto_focus = on_mount_auto_focus.clone();
+ let on_unmount_auto_focus = on_unmount_auto_focus.clone();
+ let node_ref = node_ref.clone();
+
+ on_mount(move || {
+ let document = match window().and_then(|window| window.document()) {
+ Some(document) => document,
+ None => return,
+ };
+ let root = match node_ref.get() {
+ Some(root) => root,
+ None => return,
+ };
+ let previous_focus = document.active_element();
+
+ if auto_focus {
+ let _ = radroots_app_ui_focus_scope_focus_first(&root, &document);
+ if let Some(callback) = on_mount_auto_focus.as_ref() {
+ callback.run(());
+ }
+ }
+
+ if trapped {
+ let root_focus = root.clone();
+ let document_focus = document.clone();
+ let handler = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
+ if event.key() != "Tab" {
+ return;
+ }
+ let _ = radroots_app_ui_focus_scope_cycle(
+ &root_focus,
+ &document_focus,
+ event.shift_key(),
+ );
+ }) as Box<dyn FnMut(_)>);
+ let _ = root.add_event_listener_with_callback(
+ "keydown",
+ handler.as_ref().unchecked_ref(),
+ );
+ handler.forget();
+ }
+
+ if return_focus {
+ let on_unmount_auto_focus = on_unmount_auto_focus.clone();
+ let previous_focus = previous_focus.clone();
+ on_cleanup(move || {
+ if let Some(element) = previous_focus {
+ let _ = element.dyn_ref::<web_sys::HtmlElement>().map(|el| el.focus());
+ }
+ if let Some(callback) = on_unmount_auto_focus.as_ref() {
+ callback.run(());
+ }
+ });
+ }
+ });
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let _ = trapped;
+ let _ = auto_focus;
+ let _ = return_focus;
+ let _ = on_mount_auto_focus;
+ let _ = on_unmount_auto_focus;
+ }
+
+ let on_keydown = move |event: KeyboardEvent| {
+ if trapped && event.key() == "Escape" {
+ event.prevent_default();
+ }
+ };
+
+ view! {
+ <div node_ref=node_ref tabindex="-1" on:keydown=on_keydown>
+ {children()}
+ </div>
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn radroots_app_ui_focus_scope_focus_first(
+ root: &web_sys::Element,
+ document: &web_sys::Document,
+) -> Result<(), ()> {
+ let list = root
+ .query_selector_all(RADROOTS_APP_UI_FOCUSABLE_SELECTOR)
+ .map_err(|_| ())?;
+ if list.length() == 0 {
+ if let Some(element) = root.dyn_ref::<web_sys::HtmlElement>() {
+ element.focus();
+ }
+ return Ok(());
+ }
+ let first = list
+ .item(0)
+ .and_then(|node| node.dyn_into::<web_sys::HtmlElement>().ok())
+ .or_else(|| document.active_element().and_then(|el| el.dyn_into().ok()));
+ if let Some(element) = first {
+ element.focus();
+ }
+ Ok(())
+}
+
+#[cfg(target_arch = "wasm32")]
+fn radroots_app_ui_focus_scope_cycle(
+ root: &web_sys::Element,
+ document: &web_sys::Document,
+ shift: bool,
+) -> Result<(), ()> {
+ let list = root
+ .query_selector_all(RADROOTS_APP_UI_FOCUSABLE_SELECTOR)
+ .map_err(|_| ())?;
+ let count = list.length() as usize;
+ if count == 0 {
+ return Ok(());
+ }
+ let active = document.active_element();
+ let mut current_index = 0usize;
+ if let Some(active) = active {
+ for index in 0..count {
+ let candidate = list
+ .item(index as u32)
+ .and_then(|node| node.dyn_into::<web_sys::Element>().ok());
+ if let Some(candidate) = candidate {
+ if active.is_same_node(Some(&candidate)) {
+ current_index = index;
+ break;
+ }
+ }
+ }
+ }
+ let next_index = radroots_app_ui_focus_scope_next_index(current_index, count, shift);
+ if let Some(next) = list
+ .item(next_index as u32)
+ .and_then(|node| node.dyn_into::<web_sys::HtmlElement>().ok())
+ {
+ next.focus();
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ radroots_app_ui_focus_scope_next_index,
+ radroots_app_ui_focus_scope_selector,
+ };
+
+ #[test]
+ fn focus_scope_selector_is_non_empty() {
+ assert!(!radroots_app_ui_focus_scope_selector().is_empty());
+ }
+
+ #[test]
+ fn focus_scope_next_index_wraps() {
+ assert_eq!(radroots_app_ui_focus_scope_next_index(0, 3, true), 2);
+ assert_eq!(radroots_app_ui_focus_scope_next_index(2, 3, false), 0);
+ }
+}
diff --git a/crates/ui-primitives/src/lib.rs b/crates/ui-primitives/src/lib.rs
@@ -3,6 +3,7 @@
mod portal;
mod presence;
mod dismissable;
+mod focus;
pub use portal::{RadrootsAppUiPortal, RadrootsAppUiPortalMount};
pub use presence::{
@@ -16,3 +17,8 @@ pub use dismissable::{
RadrootsAppUiDismissableLayer,
RadrootsAppUiDismissableReason,
};
+pub use focus::{
+ radroots_app_ui_focus_scope_next_index,
+ radroots_app_ui_focus_scope_selector,
+ RadrootsAppUiFocusScope,
+};