commit 7cc017b180d284bb4330177d83f9d08baf208a64
parent 3c52983fbc3dd7f4c6e9a78e6eb91d6e50b7dfd7
Author: triesap <triesap@radroots.dev>
Date: Wed, 21 Jan 2026 20:09:58 +0000
ui: add dismissable layer primitive
- add dismissable layer component with escape and outside handlers
- add dismiss reason enum and helper predicates
- add unit tests for dismissable helpers
- add web-sys event dependencies for listeners
Diffstat:
3 files changed, 167 insertions(+), 1 deletion(-)
diff --git a/crates/ui-primitives/Cargo.toml b/crates/ui-primitives/Cargo.toml
@@ -14,4 +14,13 @@ radroots-app-ui-core = { path = "../ui-core" }
leptos = { workspace = true, features = ["csr"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
-web-sys = { workspace = true, features = ["Element"] }
+web-sys = { workspace = true, features = [
+ "Document",
+ "Element",
+ "EventTarget",
+ "FocusEvent",
+ "KeyboardEvent",
+ "Node",
+ "PointerEvent",
+ "Window"
+] }
diff --git a/crates/ui-primitives/src/dismissable.rs b/crates/ui-primitives/src/dismissable.rs
@@ -0,0 +1,150 @@
+use leptos::ev::{FocusEvent, KeyboardEvent, PointerEvent};
+use leptos::html;
+use leptos::prelude::*;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RadrootsAppUiDismissableReason {
+ Escape,
+ PointerDownOutside,
+ FocusOutside,
+}
+
+pub fn radroots_app_ui_dismissable_is_escape(key: &str) -> bool {
+ key == "Escape"
+}
+
+pub fn radroots_app_ui_dismissable_is_outside(is_inside: bool) -> bool {
+ !is_inside
+}
+
+#[component]
+pub fn RadrootsAppUiDismissableLayer(
+ #[prop(optional)] on_dismiss: Option<Callback<RadrootsAppUiDismissableReason>>,
+ #[prop(optional)] on_escape_key_down: Option<Callback<KeyboardEvent>>,
+ #[prop(optional)] on_pointer_down_outside: Option<Callback<PointerEvent>>,
+ #[prop(optional)] on_focus_outside: Option<Callback<FocusEvent>>,
+ #[prop(optional)] disable_outside_pointer_events: bool,
+ children: ChildrenFn,
+) -> impl IntoView {
+ let node_ref = NodeRef::<html::Div>::new();
+
+ let on_keydown = move |event: KeyboardEvent| {
+ if !radroots_app_ui_dismissable_is_escape(&event.key()) {
+ return;
+ }
+ if let Some(callback) = on_escape_key_down.as_ref() {
+ callback.run(event.clone());
+ }
+ if let Some(callback) = on_dismiss.as_ref() {
+ callback.run(RadrootsAppUiDismissableReason::Escape);
+ }
+ };
+
+ #[cfg(target_arch = "wasm32")]
+ {
+ use wasm_bindgen::closure::Closure;
+ use wasm_bindgen::JsCast;
+
+ let on_dismiss = on_dismiss.clone();
+ let on_pointer_down_outside = on_pointer_down_outside.clone();
+ let on_focus_outside = on_focus_outside.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,
+ };
+
+ if !disable_outside_pointer_events {
+ let root_pointer = root.clone();
+ let on_dismiss = on_dismiss.clone();
+ let on_pointer_down_outside = on_pointer_down_outside.clone();
+ let handler = Closure::wrap(Box::new(move |event: web_sys::PointerEvent| {
+ let target = event
+ .target()
+ .and_then(|target| target.dyn_into::<web_sys::Node>().ok());
+ let is_inside = target
+ .as_ref()
+ .map(|node| root_pointer.contains(Some(node)))
+ .unwrap_or(false);
+ if radroots_app_ui_dismissable_is_outside(is_inside) {
+ if let Some(callback) = on_pointer_down_outside.as_ref() {
+ callback.run(event.clone());
+ }
+ if let Some(callback) = on_dismiss.as_ref() {
+ callback.run(RadrootsAppUiDismissableReason::PointerDownOutside);
+ }
+ }
+ }) as Box<dyn FnMut(_)>);
+ let _ = document.add_event_listener_with_callback(
+ "pointerdown",
+ handler.as_ref().unchecked_ref(),
+ );
+ handler.forget();
+ }
+
+ let root_focus = root.clone();
+ let on_dismiss = on_dismiss.clone();
+ let on_focus_outside = on_focus_outside.clone();
+ let focus_handler = Closure::wrap(Box::new(move |event: web_sys::FocusEvent| {
+ let target = event
+ .target()
+ .and_then(|target| target.dyn_into::<web_sys::Node>().ok());
+ let is_inside = target
+ .as_ref()
+ .map(|node| root_focus.contains(Some(node)))
+ .unwrap_or(false);
+ if radroots_app_ui_dismissable_is_outside(is_inside) {
+ if let Some(callback) = on_focus_outside.as_ref() {
+ callback.run(event.clone());
+ }
+ if let Some(callback) = on_dismiss.as_ref() {
+ callback.run(RadrootsAppUiDismissableReason::FocusOutside);
+ }
+ }
+ }) as Box<dyn FnMut(_)>);
+ let _ = document.add_event_listener_with_callback(
+ "focusin",
+ focus_handler.as_ref().unchecked_ref(),
+ );
+ focus_handler.forget();
+ });
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let _ = on_pointer_down_outside;
+ let _ = on_focus_outside;
+ let _ = disable_outside_pointer_events;
+ }
+
+ view! {
+ <div node_ref=node_ref on:keydown=on_keydown>
+ {children()}
+ </div>
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ radroots_app_ui_dismissable_is_escape,
+ radroots_app_ui_dismissable_is_outside,
+ };
+
+ #[test]
+ fn dismissable_escape_match() {
+ assert!(radroots_app_ui_dismissable_is_escape("Escape"));
+ assert!(!radroots_app_ui_dismissable_is_escape("Enter"));
+ }
+
+ #[test]
+ fn dismissable_outside_check() {
+ assert!(radroots_app_ui_dismissable_is_outside(false));
+ assert!(!radroots_app_ui_dismissable_is_outside(true));
+ }
+}
diff --git a/crates/ui-primitives/src/lib.rs b/crates/ui-primitives/src/lib.rs
@@ -2,6 +2,7 @@
mod portal;
mod presence;
+mod dismissable;
pub use portal::{RadrootsAppUiPortal, RadrootsAppUiPortalMount};
pub use presence::{
@@ -9,3 +10,9 @@ pub use presence::{
RadrootsAppUiPresence,
RadrootsAppUiPresenceState,
};
+pub use dismissable::{
+ radroots_app_ui_dismissable_is_escape,
+ radroots_app_ui_dismissable_is_outside,
+ RadrootsAppUiDismissableLayer,
+ RadrootsAppUiDismissableReason,
+};