app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mcrates/ui-primitives/Cargo.toml | 11++++++++++-
Acrates/ui-primitives/src/dismissable.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ui-primitives/src/lib.rs | 7+++++++
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, +};