From e2a0cb99b9f3e75711edd218afd31c1a928831ce Mon Sep 17 00:00:00 2001 From: Aleksander Heintz Date: Sat, 2 Aug 2025 15:53:31 +0200 Subject: [PATCH] feat: leptos dismissable-layer --- Cargo.lock | 85 +++ Cargo.toml | 8 +- .../leptos/dismissable-layer/Cargo.toml | 11 + .../src/dismissable_layer.rs | 715 +++++++++++++++++- .../leptos/dismissable-layer/src/lib.rs | 1 + .../dismissable-layer/src/object_key.rs | 88 +++ .../leptos/use-escape-keydown/Cargo.toml | 1 + .../src/use_escape_keydown.rs | 7 +- 8 files changed, 907 insertions(+), 9 deletions(-) create mode 100644 packages/primitives/leptos/dismissable-layer/src/object_key.rs diff --git a/Cargo.lock b/Cargo.lock index ba52be4a..00990b96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,6 +454,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -569,6 +580,18 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "default-struct-builder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "deranged" version = "0.4.0" @@ -2277,6 +2300,31 @@ dependencies = [ "leptos", ] +[[package]] +name = "leptos-use" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6eac17e7d306b4ad67158aba97c1490884ba304add4321069cb63fe0834c3b1" +dependencies = [ + "cfg-if", + "chrono", + "codee", + "cookie", + "default-struct-builder", + "futures-util", + "gloo-timers 0.3.0", + "js-sys", + "lazy_static", + "leptos", + "paste", + "send_wrapper", + "thiserror 2.0.12", + "unic-langid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "leptos_config" version = "0.8.5" @@ -3151,6 +3199,24 @@ dependencies = [ "leptos", ] +[[package]] +name = "radix-leptos-dismissable-layer" +version = "0.0.2" +dependencies = [ + "indexmap", + "leptos", + "leptos-maybe-callback", + "leptos-node-ref", + "leptos-use", + "radix-leptos-compose-refs", + "radix-leptos-primitive", + "radix-leptos-use-escape-keydown", + "send_wrapper", + "serde", + "serde-wasm-bindgen 0.6.5", + "wasm-bindgen", +] + [[package]] name = "radix-leptos-focus-guards" version = "0.0.2" @@ -3268,6 +3334,7 @@ name = "radix-leptos-use-escape-keydown" version = "0.0.2" dependencies = [ "leptos", + "leptos-maybe-callback", "send_wrapper", "web-sys", ] @@ -4900,6 +4967,24 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicode-ident" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index fe426115..645a3731 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "packages/primitives/leptos/arrow", "packages/primitives/leptos/aspect-ratio", "packages/primitives/leptos/compose-refs", + "packages/primitives/leptos/dismissable-layer", "packages/primitives/leptos/direction", "packages/primitives/leptos/focus-guards", "packages/primitives/leptos/id", @@ -48,12 +49,14 @@ version = "0.0.2" console_error_panic_hook = "0.1.7" console_log = "1.0.0" dioxus = "0.6.1" +indexmap = "2.10.0" leptos = "0.8.0" leptos_dom = "0.8.0" leptos_router = "0.8.0" leptos-maybe-callback = "0.2.0" leptos-node-ref = "0.2.0" leptos-style = "0.2.0" +leptos-use = "0.16.0" leptos-typed-fallback-show = "0.2.0" log = "0.4.22" radix-dioxus-icons = { path = "./packages/icons/dioxus", version = "0.0.2" } @@ -62,7 +65,7 @@ radix-leptos-arrow = { path = "./packages/primitives/leptos/arrow", version = "0 radix-leptos-aspect-ratio = { path = "./packages/primitives/leptos/aspect-ratio", version = "0.0.2" } # radix-leptos-collection = { path = "./packages/primitives/leptos/collection", version = "0.0.2" } radix-leptos-compose-refs = { path = "./packages/primitives/leptos/compose-refs", version = "0.0.2" } -# radix-leptos-dismissable-layer = { path = "./packages/primitives/leptos/dismissable-layer", version = "0.0.2" } +radix-leptos-dismissable-layer = { path = "./packages/primitives/leptos/dismissable-layer", version = "0.0.2" } radix-leptos-direction = { path = "./packages/primitives/leptos/direction", version = "0.0.2" } radix-leptos-focus-guards = { path = "./packages/primitives/leptos/focus-guards", version = "0.0.2" } # radix-leptos-focus-scope = { path = "./packages/primitives/leptos/focus-scope", version = "0.0.2" } @@ -77,6 +80,7 @@ radix-leptos-primitive = { path = "./packages/primitives/leptos/primitive", vers radix-leptos-separator = { path = "./packages/primitives/leptos/separator", version = "0.0.2" } radix-leptos-visually-hidden = { path = "./packages/primitives/leptos/visually-hidden", version = "0.0.2" } radix-leptos-use-controllable-state = { path = "./packages/primitives/leptos/use-controllable-state", version = "0.0.2" } +radix-leptos-use-escape-keydown = { path = "./packages/primitives/leptos/use-escape-keydown", version = "0.0.2" } radix-leptos-use-previous = { path = "./packages/primitives/leptos/use-previous", version = "0.0.2" } radix-leptos-use-size = { path = "./packages/primitives/leptos/use-size", version = "0.0.2" } radix-number = { path = "./packages/primitives/core/number", version = "0.0.2" } @@ -112,7 +116,9 @@ radix-yew-use-size = { path = "./packages/primitives/yew/use-size", version = "0 send_wrapper = "0.6.0" serde = "1.0.198" serde_json = "1.0.116" +serde-wasm-bindgen = "0.6.5" tailwind_fuse = { version = "0.3.0", features = ["variant"] } +wasm-bindgen = "0.2.100" web-sys = "0.3.72" yew = "0.21.0" yew-router = "0.18.0" diff --git a/packages/primitives/leptos/dismissable-layer/Cargo.toml b/packages/primitives/leptos/dismissable-layer/Cargo.toml index 73f92ca7..1b157696 100644 --- a/packages/primitives/leptos/dismissable-layer/Cargo.toml +++ b/packages/primitives/leptos/dismissable-layer/Cargo.toml @@ -9,4 +9,15 @@ repository.workspace = true version.workspace = true [dependencies] +indexmap.workspace = true leptos.workspace = true +leptos-maybe-callback.workspace = true +leptos-node-ref.workspace = true +leptos-use.workspace = true +radix-leptos-primitive.workspace = true +radix-leptos-compose-refs.workspace = true +radix-leptos-use-escape-keydown.workspace = true +send_wrapper.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +wasm-bindgen.workspace = true diff --git a/packages/primitives/leptos/dismissable-layer/src/dismissable_layer.rs b/packages/primitives/leptos/dismissable-layer/src/dismissable_layer.rs index ec565cb9..6ccf2c42 100644 --- a/packages/primitives/leptos/dismissable-layer/src/dismissable_layer.rs +++ b/packages/primitives/leptos/dismissable-layer/src/dismissable_layer.rs @@ -1,18 +1,725 @@ -use leptos::ev::{CustomEvent, FocusEvent, PointerEvent}; +use std::{ + sync::{Arc, LazyLock, Mutex, atomic::AtomicBool}, + time::Duration, +}; +use crate::object_key::{ObjectId, ObjectKey}; +use indexmap::{IndexMap, IndexSet}; +use leptos::{ + ev::{CustomEvent, Event, FocusEvent, KeyboardEvent, PointerEvent}, + html, + prelude::*, + web_sys::{AddEventListenerOptions, CustomEventInit, Element, EventListenerOptions}, +}; +use leptos_maybe_callback::MaybeCallback; +use leptos_node_ref::AnyNodeRef; +use radix_leptos_compose_refs::use_composed_refs; +use radix_leptos_primitive::Primitive; +use radix_leptos_use_escape_keydown::use_escape_keydown; +use send_wrapper::SendWrapper; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::{JsCast, prelude::Closure}; +use wasm_bindgen::{JsValue, intern}; + +mod strings { + use wasm_bindgen::intern; + + const POINTER_DOWN_OUTSIDE: &str = "dismissableLayer.pointerDownOutside"; + const FOCUS_OUTSIDE: &str = "dismissableLayer.focusOutside"; + + pub fn pointer_down_outside() -> &'static str { + intern(POINTER_DOWN_OUTSIDE) + } + + pub fn focus_outside() -> &'static str { + intern(FOCUS_OUTSIDE) + } +} + +#[derive(Serialize, Deserialize)] pub struct PointerDownOutsideEventDetail { + #[serde(with = "serde_wasm_bindgen::preserve")] pub original_event: PointerEvent, } pub type PointerDownOutsideEvent = CustomEvent; +#[derive(Serialize, Deserialize)] pub struct FocusOutsideEventDetail { + #[serde(with = "serde_wasm_bindgen::preserve")] pub original_event: FocusEvent, } pub type FocusOutsideEvent = CustomEvent; -pub enum InteractOutsideEvent { - PointerDownOutside(PointerDownOutsideEvent), - FocusOutside(FocusOutsideEvent), +pub type InteractOutsideEvent = CustomEvent; + +pub trait DismissableLayerEventDetail: Serialize { + fn original_event(&self) -> &Event; +} + +impl DismissableLayerEventDetail for PointerDownOutsideEventDetail { + fn original_event(&self) -> &Event { + &self.original_event + } +} + +impl DismissableLayerEventDetail for FocusOutsideEventDetail { + fn original_event(&self) -> &Event { + &self.original_event + } +} + +#[derive(Debug)] +struct LayerInfo { + outside_pointer_events_disabled: bool, +} + +#[derive(Debug, Default)] +struct DismissableLayerContextInner { + layers: IndexMap, LayerInfo>, + branches: IndexSet>, +} + +type DismissableLayerContext = RwSignal; +// #[derive(Debug, Default, Clone)] +// struct DismissableLayerContext { +// inner: RwSignal, +// } + +fn use_or_provide_context() -> T { + if let Some(context) = with_context(|ctx: &T| ctx.clone()) { + context + } else { + let context = T::default(); + provide_context(context.clone()); + context + } +} + +static ORIGINAL_BODY_POINTER_EVENTS: LazyLock> = + LazyLock::new(|| Mutex::new(String::new())); + +#[component] +pub fn DismissableLayer( + /// When `true`, hover/focus/click interactions will be disabled on elements outside + /// the `DismissableLayer`. Users will need to click twice on outside elements to + /// interact with them: once to close the `DismissableLayer`, and again to trigger the element. + #[prop(into, optional, default = false.into())] + disable_outside_pointer_events: MaybeProp, + /// Event handler called when the escape key is down. + /// Can be prevented. + #[prop(into, optional)] + on_escape_key_down: MaybeCallback, + /// Event handler called when a `pointerdown` event happens outside of the `DismissableLayer`. + /// Can be prevented. + #[prop(into, optional)] + on_pointer_down_outside: MaybeCallback, + /// Event handler called when the focus moves outside of the `DismissableLayer`. + /// Can be prevented. + #[prop(into, optional)] + on_focus_outside: MaybeCallback, + /// Event handler called when an interaction happens outside of the `DismissableLayer`. + /// Specifically, when a `pointerdown` event happens outside or focus moves outside of it. + /// Can be prevented. + #[prop(into, optional)] + on_interact_outside: MaybeCallback, + /// Handler called when the `DismissableLayer` should be dismissed. + #[prop(into, optional)] + on_dismiss: MaybeCallback<()>, + #[prop(optional)] node_ref: AnyNodeRef, + children: ChildrenFn, +) -> impl IntoView { + let context = use_or_provide_context::(); + let node = AnyNodeRef::new(); + let composed_refs = use_composed_refs(vec![node_ref, node]); + let index = Signal::derive({ + let node = node.clone(); + + move || { + let Some(node) = node.get() else { + return None; + }; + + context.with(|context| context.layers.keys().position(|key| key.as_ref() == &node)) + } + }); + let highest_layer_with_outside_pointer_events_disabled_index = Signal::derive({ + let context = context.clone(); + + move || { + context.with(|context| { + context + .layers + .values() + .rposition(|layer| layer.outside_pointer_events_disabled) + }) + } + }); + let is_body_pointer_events_disabled = Signal::derive({ + let context = context.clone(); + + move || { + context.with(|context| { + context + .layers + .values() + .any(|layer| layer.outside_pointer_events_disabled) + }) + } + }); + + let is_pointer_events_enabled = Signal::derive(move || { + index.get() >= highest_layer_with_outside_pointer_events_disabled_index.get() + }); + + use_pointer_down_outside( + MaybeCallback::from({ + let context = context.clone(); + let on_interact_outside = on_interact_outside.clone(); + let on_dismiss = on_dismiss.clone(); + + move |event: PointerDownOutsideEvent| { + let target = event + .target() + .map(|target| target.unchecked_into::()); + let is_pointer_down_on_branch = context.with(|context| { + context + .branches + .iter() + .any(|branch| branch.contains(target.as_ref())) + }); + + if !is_pointer_events_enabled.get() || is_pointer_down_on_branch { + return; + } + + on_pointer_down_outside.run(event.clone()); + on_interact_outside.run(event.clone()); + + if !event.default_prevented() { + on_dismiss.run(()); + } + } + }), + node.clone(), + ); + + use_focus_outside( + MaybeCallback::from({ + let context = context.clone(); + let on_dismiss = on_dismiss.clone(); + + move |event: FocusOutsideEvent| { + let target = event + .target() + .map(|target| target.unchecked_into::()); + + let is_focus_in_branch = context.with(|context| { + context + .branches + .iter() + .any(|branch| branch.contains(target.as_ref())) + }); + + if is_focus_in_branch { + return; + } + + on_focus_outside.run(event.clone()); + on_interact_outside.run(event.clone()); + + if !event.default_prevented() { + on_dismiss.run(()); + } + } + }), + node.clone(), + ); + + use_escape_keydown( + MaybeCallback::from({ + let context = context.clone(); + + move |event: KeyboardEvent| { + let is_highest_layer = + index.get() == Some(context.with(|context| context.layers.len()) - 1); + if !is_highest_layer { + return; + } + + on_escape_key_down.run(event.clone()); + if !event.default_prevented() { + on_dismiss.run(()); + } + } + }), + None, // TODO: This needs to accept a signal. The node value isn't yet available + ); + + Effect::watch( + move || { + node.track(); + disable_outside_pointer_events.track(); + }, + move |(), _, _| { + let Some(node) = node.get() else { return }; + let disable_outside_pointer_events = + disable_outside_pointer_events.get().unwrap_or(false); + let owner_document = node.owner_document().unwrap(); + + { + let mut context = context.write(); + + if disable_outside_pointer_events { + if !context + .layers + .values() + .any(|layer| layer.outside_pointer_events_disabled) + { + let body_styles = owner_document.body().unwrap().style(); + *ORIGINAL_BODY_POINTER_EVENTS.lock().unwrap() = body_styles + .get_property_value(intern("pointer-events")) + .unwrap(); + body_styles + .set_property(intern("pointer-events"), intern("none")) + .unwrap(); + } + } + + context.layers.insert( + ObjectKey::new(node.clone()).unwrap(), + LayerInfo { + outside_pointer_events_disabled: disable_outside_pointer_events, + }, + ); + } + + on_cleanup({ + let owner_document = SendWrapper::new(owner_document); + + move || { + if disable_outside_pointer_events + && context + .read() + .layers + .values() + .filter(|layer| layer.outside_pointer_events_disabled) + .skip(1) + .any(|_| true) + { + let body_styles = owner_document.body().unwrap().style(); + let original_value = ORIGINAL_BODY_POINTER_EVENTS.lock().unwrap(); + body_styles + .set_property(intern("pointer-events"), &original_value) + .unwrap(); + } + } + }); + }, + false, + ); + + /* + * We purposefully prevent combining this effect with the `disableOutsidePointerEvents` effect + * because a change to `disableOutsidePointerEvents` would remove this layer from the stack + * and add it to the end again so the layering order wouldn't be _creation order_. + * We only want them to be removed from context stacks when unmounted. + */ + Effect::watch( + move || { + node.track(); + }, + move |(), _, _| { + on_cleanup(move || { + let Some(node) = node.get() else { return }; + let key = ObjectId::for_value(&node).unwrap(); + context.write().layers.shift_remove(&key); + }); + }, + false, + ); + + let pointer_events = Signal::derive(move || { + match ( + is_body_pointer_events_disabled.get(), + is_pointer_events_enabled.get(), + ) { + (true, true) => intern("auto"), + (true, false) => intern("none"), + (false, _) => "", + } + }); + + view! { + + {children()} + + } +} + +/// Listens for `pointerdown` outside a subtree. We use `pointerdown` rather than `pointerup` +/// to mimic layer dismissing behaviour present in OS. +fn use_pointer_down_outside( + on_pointer_down_outside: MaybeCallback, + node: AnyNodeRef, +) { + ClientOnly::new(move || { + let handle_pointer_down_outside = Closure::::new( + move |event: PointerDownOutsideEvent| { + #[cfg(debug_assertions)] + let _z = leptos::reactive::diagnostics::SpecialNonReactiveZone::enter(); + + if let Some(ref cb) = on_pointer_down_outside.0.as_ref() { + cb.run(event); + } + }, + ) + .into_js_value(); + + let is_pointer_inside_react_tree = Arc::new(AtomicBool::new(false)); + let handle_click: ArcRwSignal>> = ArcRwSignal::new(None); + + Effect::watch( + move || node.get(), + move |node, _, _| { + let Some(node) = node.clone() else { + return; + }; + + let Some(owner_document) = node.owner_document() else { + return; + }; + + let capture_on_pointer_down = SendWrapper::new( + Closure::::new({ + let is_pointer_inside_react_tree = + Arc::clone(&is_pointer_inside_react_tree); + move || { + is_pointer_inside_react_tree + .store(true, std::sync::atomic::Ordering::Relaxed); + } + }) + .into_js_value(), + ); + + { + let options = AddEventListenerOptions::new(); + options.set_capture(true); + _ = node.add_event_listener_with_callback_and_add_event_listener_options( + intern("pointerdown"), + capture_on_pointer_down.as_ref().unchecked_ref(), + &options, + ); + } + + let handle_pointer_down = SendWrapper::new( + Closure::::new({ + let handle_click = handle_click.clone(); + let owner_document = owner_document.clone(); + let handle_pointer_down_outside = handle_pointer_down_outside.clone(); + let is_pointer_inside_react_tree = + Arc::clone(&is_pointer_inside_react_tree); + move |event: PointerEvent| { + if event.target().is_some() + && !is_pointer_inside_react_tree + .load(std::sync::atomic::Ordering::Relaxed) + { + let event_detail = PointerDownOutsideEventDetail { + original_event: event.clone(), + }; + + let handle_and_dispatch_pointer_down_outside_event = { + let handle_pointer_down_outside = + handle_pointer_down_outside.clone(); + move || { + handle_and_dispatch_custom_event( + strings::pointer_down_outside(), + Some(&handle_pointer_down_outside), + event_detail, + ); + } + }; + + /* + * On touch devices, we need to wait for a click event because browsers implement + * a ~350ms delay between the time the user stops touching the display and when the + * browser executres events. We need to ensure we don't reactivate pointer-events within + * this timeframe otherwise the browser may execute events that should have been prevented. + * + * Additionally, this also lets us deal automatically with cancellations when a click event + * isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc. + * + * This is why we also continuously remove the previous listener, because we cannot be + * certain that it was raised, and therefore cleaned-up. + */ + if &event.pointer_type() == "touch" { + if let Some(handle_click) = handle_click.read().as_ref() { + _ = owner_document.remove_event_listener_with_callback( + intern("click"), + handle_click.as_ref().unchecked_ref(), + ); + } + + let closure = Closure::::once( + handle_and_dispatch_pointer_down_outside_event, + ) + .into_js_value(); + handle_click.set(Some(SendWrapper::new(closure.clone()))); + + let options = AddEventListenerOptions::new(); + options.set_once(true); + _ = owner_document + .add_event_listener_with_callback_and_add_event_listener_options( + intern("click"), + closure.as_ref().unchecked_ref(), + &options, + ); + } + } else { + // We need to remove the event listener in case the outside click has been canceled. + // See: https://github.com/radix-ui/primitives/issues/2171 + if let Some(handle_click) = handle_click.read().as_ref() { + _ = owner_document.remove_event_listener_with_callback( + intern("click"), + handle_click.as_ref().unchecked_ref(), + ); + } + } + + is_pointer_inside_react_tree + .store(false, std::sync::atomic::Ordering::Relaxed); + } + }) + .into_js_value(), + ); + + /* + * if this hook executes in a component that mounts via a `pointerdown` event, the event + * would bubble up to the document and trigger a `pointerDownOutside` event. We avoid + * this by delaying the event listener registration on the document. + * This is not React specific, but rather how the DOM works, ie: + * ``` + * button.addEventListener('pointerdown', () => { + * console.log('I will log'); + * document.addEventListener('pointerdown', () => { + * console.log('I will also log'); + * }) + * }); + */ + let timeout = set_timeout_with_handle( + { + let owner_document = owner_document.clone(); + let handle_pointer_down = handle_pointer_down.clone(); + move || { + _ = owner_document.add_event_listener_with_callback( + intern("pointerdown"), + handle_pointer_down.as_ref().unchecked_ref(), + ); + } + }, + Duration::ZERO, + ) + .unwrap(); + + on_cleanup({ + let handle_click = handle_click.clone(); + let owner_document = SendWrapper::new(owner_document); + let node = SendWrapper::new(node); + + move || { + timeout.clear(); + _ = owner_document.remove_event_listener_with_callback( + intern("pointerdown"), + handle_pointer_down.as_ref().unchecked_ref(), + ); + if let Some(handle_click) = handle_click.read().as_ref() { + _ = owner_document.remove_event_listener_with_callback( + intern("click"), + handle_click.as_ref().unchecked_ref(), + ); + } + + let options = EventListenerOptions::new(); + options.set_capture(true); + _ = node.remove_event_listener_with_callback_and_event_listener_options( + intern("pointerdown"), + capture_on_pointer_down.as_ref().unchecked_ref(), + &options, + ); + } + }); + }, + false, + ); + }); +} + +/// Listens for when focus happens outside a subtree. +fn use_focus_outside(on_focus_outside: MaybeCallback, node: AnyNodeRef) { + ClientOnly::new(move || { + let handle_focus_outside = + Closure::::new(move |event: FocusOutsideEvent| { + #[cfg(debug_assertions)] + let _z = leptos::reactive::diagnostics::SpecialNonReactiveZone::enter(); + + if let Some(ref cb) = on_focus_outside.0.as_ref() { + cb.run(event); + } + }) + .into_js_value(); + + let is_focus_inside_react_tree = Arc::new(AtomicBool::new(false)); + + Effect::watch( + move || node.get(), + move |node, _, _| { + let Some(node) = node.clone() else { + return; + }; + + let Some(owner_document) = node.owner_document() else { + return; + }; + + let capture_on_focusin = SendWrapper::new( + Closure::::new({ + let is_focus_inside_react_tree = Arc::clone(&is_focus_inside_react_tree); + move || { + is_focus_inside_react_tree + .store(true, std::sync::atomic::Ordering::Relaxed); + } + }) + .into_js_value(), + ); + + let capture_on_focusout = SendWrapper::new( + Closure::::new({ + let is_focus_inside_react_tree = Arc::clone(&is_focus_inside_react_tree); + move || { + is_focus_inside_react_tree + .store(false, std::sync::atomic::Ordering::Relaxed); + } + }) + .into_js_value(), + ); + + { + let options = AddEventListenerOptions::new(); + options.set_capture(true); + _ = node.add_event_listener_with_callback_and_add_event_listener_options( + intern("focusin"), + capture_on_focusin.as_ref().unchecked_ref(), + &options, + ); + _ = node.add_event_listener_with_callback_and_add_event_listener_options( + intern("focusout"), + capture_on_focusout.as_ref().unchecked_ref(), + &options, + ); + } + + let handle_focus = SendWrapper::new( + Closure::::new({ + let handle_focus_outside = handle_focus_outside.clone(); + let is_focus_inside_react_tree = Arc::clone(&is_focus_inside_react_tree); + + move |event: FocusEvent| { + if event.target().is_some() + && !is_focus_inside_react_tree + .load(std::sync::atomic::Ordering::Relaxed) + { + let event_detail = FocusOutsideEventDetail { + original_event: event, + }; + handle_and_dispatch_custom_event( + strings::focus_outside(), + Some(&handle_focus_outside), + event_detail, + ); + } + } + }) + .into_js_value(), + ); + + _ = owner_document.add_event_listener_with_callback( + intern("focusin"), + handle_focus.as_ref().unchecked_ref(), + ); + + on_cleanup({ + let owner_document = SendWrapper::new(owner_document); + let node = SendWrapper::new(node); + + move || { + _ = owner_document.remove_event_listener_with_callback( + intern("focusin"), + handle_focus.as_ref().unchecked_ref(), + ); + + let options = EventListenerOptions::new(); + options.set_capture(true); + _ = node.remove_event_listener_with_callback_and_event_listener_options( + intern("focusin"), + capture_on_focusin.as_ref().unchecked_ref(), + &options, + ); + _ = node.remove_event_listener_with_callback_and_event_listener_options( + intern("focusout"), + capture_on_focusout.as_ref().unchecked_ref(), + &options, + ); + } + }); + }, + false, + ); + }); +} + +fn handle_and_dispatch_custom_event( + name: &'static str, + handler: Option<&JsValue>, + detail: E, +) { + let target = detail.original_event().target().unwrap(); + let event = { + let init = CustomEventInit::new(); + init.set_bubbles(false); + init.set_cancelable(true); + init.set_detail(&serde_wasm_bindgen::to_value(&detail).unwrap()); + CustomEvent::new_with_event_init_dict(name, &init).unwrap() + }; + + if let Some(handler) = handler { + let options = AddEventListenerOptions::new(); + options.set_once(true); + target + .add_event_listener_with_callback_and_add_event_listener_options( + name, + handler.as_ref().unchecked_ref(), + &options, + ) + .unwrap(); + } + + target.dispatch_event(&event.unchecked_ref()).unwrap(); +} + +struct ClientOnly { + #[allow(dead_code)] + effect: Effect, +} + +impl ClientOnly { + pub fn new(mut handler: impl FnMut() + 'static) -> Self { + let effect = Effect::watch(move || (), move |(), _, _| handler(), true); + + Self { effect } + } } diff --git a/packages/primitives/leptos/dismissable-layer/src/lib.rs b/packages/primitives/leptos/dismissable-layer/src/lib.rs index bdd25107..4dc579ab 100644 --- a/packages/primitives/leptos/dismissable-layer/src/lib.rs +++ b/packages/primitives/leptos/dismissable-layer/src/lib.rs @@ -5,5 +5,6 @@ //! See [`@radix-ui/react-dismissable-layer`](https://www.npmjs.com/package/@radix-ui/react-dismissable-layer) for the original package. mod dismissable_layer; +mod object_key; pub use dismissable_layer::*; diff --git a/packages/primitives/leptos/dismissable-layer/src/object_key.rs b/packages/primitives/leptos/dismissable-layer/src/object_key.rs new file mode 100644 index 00000000..2bdb5583 --- /dev/null +++ b/packages/primitives/leptos/dismissable-layer/src/object_key.rs @@ -0,0 +1,88 @@ +use std::{ + hash::{Hash, Hasher}, + ops::Deref, + sync::{ + LazyLock, + atomic::{AtomicU32, Ordering}, + }, +}; + +use indexmap::Equivalent; +use leptos::web_sys::js_sys::{Object, Reflect}; +use send_wrapper::SendWrapper; +use wasm_bindgen::JsValue; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ObjectId(u32); + +static NEXT_ID: AtomicU32 = AtomicU32::new(0); +static ID_SYMBOL: LazyLock> = + LazyLock::new(move || SendWrapper::new(JsValue::symbol(Some("node_id")))); + +impl ObjectId { + pub fn for_object(object: &Object) -> Result { + let symbol = LazyLock::force(&ID_SYMBOL); + match Reflect::get(object, symbol)? { + value if value.is_undefined() => { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let value = JsValue::from(id); + Reflect::set(object, symbol, &value)?; + Ok(ObjectId(id)) + } + value => Ok(ObjectId(u64::try_from(value)? as u32)), + } + } + + pub fn for_value(value: &impl AsRef) -> Result { + Self::for_object(value.as_ref()) + } +} + +#[derive(Debug, Clone)] +pub struct ObjectKey { + id: ObjectId, + value: SendWrapper, +} + +impl> ObjectKey { + pub fn new(value: T) -> Result { + ObjectId::for_object(value.as_ref()).map(move |id| Self { + id, + value: SendWrapper::new(value), + }) + } +} + +impl PartialEq for ObjectKey { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for ObjectKey {} + +impl Hash for ObjectKey { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +impl AsRef for ObjectKey { + fn as_ref(&self) -> &T { + &self.value + } +} + +impl Deref for ObjectKey { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl Equivalent> for ObjectId { + fn equivalent(&self, key: &ObjectKey) -> bool { + self == &key.id + } +} diff --git a/packages/primitives/leptos/use-escape-keydown/Cargo.toml b/packages/primitives/leptos/use-escape-keydown/Cargo.toml index fa89c99a..6dcba900 100644 --- a/packages/primitives/leptos/use-escape-keydown/Cargo.toml +++ b/packages/primitives/leptos/use-escape-keydown/Cargo.toml @@ -10,5 +10,6 @@ version.workspace = true [dependencies] leptos.workspace = true +leptos-maybe-callback.workspace = true send_wrapper.workspace = true web-sys = { workspace = true, features = ["EventListenerOptions"] } diff --git a/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs b/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs index 0472dee1..92d95c6c 100644 --- a/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs +++ b/packages/primitives/leptos/use-escape-keydown/src/use_escape_keydown.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use leptos::{ev::KeyboardEvent, prelude::*}; +use leptos_maybe_callback::MaybeCallback; use send_wrapper::SendWrapper; use web_sys::{ AddEventListenerOptions, Document, EventListenerOptions, @@ -9,7 +10,7 @@ use web_sys::{ /// Listens for when the escape key is down. pub fn use_escape_keydown( - on_escape_key_down: Option>, + on_escape_key_down: MaybeCallback, owner_document: Option, ) { let owner_document = StoredValue::new(SendWrapper::new(owner_document.unwrap_or(document()))); @@ -17,9 +18,7 @@ pub fn use_escape_keydown( type HandleKeyDown = dyn Fn(KeyboardEvent); let handle_key_down: Arc>> = Arc::new(SendWrapper::new( Closure::new(move |event: KeyboardEvent| { - if event.key() == "Escape" - && let Some(on_escape_key_down) = on_escape_key_down - { + if event.key() == "Escape" { on_escape_key_down.run(event); } }),