leptos_use/
on_click_outside.rs

1use crate::core::{ElementsMaybeSignal, IntoElementMaybeSignal, IntoElementsMaybeSignal};
2use cfg_if::cfg_if;
3use default_struct_builder::DefaultBuilder;
4
5cfg_if! { if #[cfg(not(feature = "ssr"))] {
6    use leptos::prelude::*;
7    use crate::utils::IS_IOS;
8    use crate::{use_event_listener, use_event_listener_with_options, UseEventListenerOptions, sendwrap_fn};
9    use leptos::ev::{blur, click, pointerdown};
10    use std::cell::Cell;
11    use std::rc::Rc;
12    use std::sync::RwLock;
13    use std::time::Duration;
14    use wasm_bindgen::JsCast;
15
16    static IOS_WORKAROUND: RwLock<bool> = RwLock::new(false);
17}}
18
19/// Listen for clicks outside an element.
20/// Useful for modals or dropdowns.
21///
22/// ## Demo
23///
24/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/on_click_outside)
25///
26/// ## Usage
27///
28/// ```
29/// # use leptos::prelude::*;
30/// # use leptos::logging::log;
31/// # use leptos::html::Div;
32/// # use leptos_use::on_click_outside;
33/// #
34/// # #[component]
35/// # fn Demo() -> impl IntoView {
36/// let target = NodeRef::<Div>::new();
37///
38/// on_click_outside(target, move |event| { log!("{:?}", event); });
39///
40/// view! {
41///     <div node_ref=target>"Hello World"</div>
42///     <div>"Outside element"</div>
43/// }
44/// # }
45/// ```
46///
47/// > This function uses [Event.composedPath()](https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath)
48/// > which is **not** supported by IE 11, Edge 18 and below.
49/// > If you are targeting these browsers, we recommend you to include
50/// > [this code snippet](https://gist.github.com/sibbng/13e83b1dd1b733317ce0130ef07d4efd) on your project.
51///
52/// ## SendWrapped Return
53///
54/// The return value of this function is a sendwrapped function to remove all event listeners. It can
55/// only be called from the same thread that called `on_click_outside`.
56///
57/// ## Excluding Elements
58///
59/// Use this to ignore clicks on certain elements.
60///
61/// ```
62/// # use leptos::prelude::*;
63/// # use leptos::logging::log;
64/// # use leptos::html::Div;
65/// # use leptos_use::{on_click_outside_with_options, OnClickOutsideOptions};
66/// #
67/// # #[component]
68/// # fn Demo() -> impl IntoView {
69/// # let target = NodeRef::<Div>::new();
70/// #
71/// on_click_outside_with_options(
72///     target,
73///     move |event| { log!("{:?}", event); },
74///     OnClickOutsideOptions::default().ignore(["input", "#some-id"]),
75/// );
76/// #
77/// # view! {
78/// #     <div node_ref=target>"Hello World"</div>
79/// # }
80/// # }
81///
82/// ```
83///
84/// ## Server-Side Rendering
85///
86/// On the server this amounts to a no-op.
87pub fn on_click_outside<El, M, F>(target: El, handler: F) -> impl FnOnce() + Clone + Send + Sync
88where
89    El: IntoElementMaybeSignal<web_sys::EventTarget, M>,
90    F: FnMut(web_sys::Event) + Clone + 'static,
91{
92    on_click_outside_with_options(target, handler, OnClickOutsideOptions::default())
93}
94
95/// Version of `on_click_outside` that takes an `OnClickOutsideOptions`. See `on_click_outside` for more details.
96#[cfg_attr(feature = "ssr", allow(unused_variables))]
97pub fn on_click_outside_with_options<El, M, F>(
98    target: El,
99    handler: F,
100    options: OnClickOutsideOptions,
101) -> impl FnOnce() + Clone + Send + Sync
102where
103    El: IntoElementMaybeSignal<web_sys::EventTarget, M>,
104    F: FnMut(web_sys::Event) + Clone + 'static,
105{
106    #[cfg(feature = "ssr")]
107    {
108        || {}
109    }
110
111    #[cfg(not(feature = "ssr"))]
112    {
113        let OnClickOutsideOptions {
114            ignore,
115            capture,
116            detect_iframes,
117        } = options;
118
119        // Fixes: https://github.com/vueuse/vueuse/issues/1520
120        // How it works: https://stackoverflow.com/a/39712411
121        if *IS_IOS {
122            if let Ok(mut ios_workaround) = IOS_WORKAROUND.write() {
123                if !*ios_workaround {
124                    *ios_workaround = true;
125                    if let Some(body) = document().body() {
126                        let children = body.children();
127                        for i in 0..children.length() {
128                            let _ = children
129                                .get_with_index(i)
130                                .expect("checked index")
131                                .add_event_listener_with_callback(
132                                    "click",
133                                    &js_sys::Function::default(),
134                                );
135                        }
136                    }
137                }
138            }
139        }
140
141        let should_listen = Rc::new(Cell::new(true));
142
143        let should_ignore = move |event: &web_sys::UiEvent| {
144            let ignore = ignore.get_untracked();
145
146            ignore.into_iter().flatten().any(|element| {
147                event_target::<web_sys::EventTarget>(event) == element
148                    || event.composed_path().includes(element.as_ref(), 0)
149            })
150        };
151
152        let target = target.into_element_maybe_signal();
153
154        let listener = {
155            let should_listen = Rc::clone(&should_listen);
156            let mut handler = handler.clone();
157
158            move |event: web_sys::UiEvent| {
159                if let Some(el) = target.get_untracked() {
160                    if el == event_target(&event) || event.composed_path().includes(el.as_ref(), 0)
161                    {
162                        return;
163                    }
164
165                    if event.detail() == 0 {
166                        should_listen.set(!should_ignore(&event));
167                    }
168
169                    if !should_listen.get() {
170                        should_listen.set(true);
171                        return;
172                    }
173
174                    #[cfg(debug_assertions)]
175                    let _z = leptos::reactive::diagnostics::SpecialNonReactiveZone::enter();
176
177                    handler(event.into());
178                }
179            }
180        };
181
182        let remove_click_listener = {
183            let mut listener = listener.clone();
184
185            use_event_listener_with_options::<_, web_sys::Window, _, _>(
186                window(),
187                click,
188                move |event| listener(event.into()),
189                UseEventListenerOptions::default()
190                    .passive(true)
191                    .capture(capture),
192            )
193        };
194
195        let remove_pointer_listener = {
196            let should_listen = Rc::clone(&should_listen);
197
198            use_event_listener_with_options::<_, web_sys::Window, _, _>(
199                window(),
200                pointerdown,
201                move |event| {
202                    if let Some(el) = target.get_untracked() {
203                        should_listen
204                            .set(!event.composed_path().includes(&el, 0) && !should_ignore(&event));
205                    }
206                },
207                UseEventListenerOptions::default().passive(true),
208            )
209        };
210
211        let remove_blur_listener = if detect_iframes {
212            Some(use_event_listener::<_, web_sys::Window, _, _>(
213                window(),
214                blur,
215                move |event| {
216                    let mut handler = handler.clone();
217
218                    let _ = set_timeout_with_handle(
219                        move || {
220                            if let Some(el) = target.get_untracked() {
221                                if let Some(active_element) = document().active_element() {
222                                    if active_element.tag_name() == "IFRAME"
223                                        && !el
224                                            .unchecked_into::<web_sys::Node>()
225                                            .contains(Some(&active_element.into()))
226                                    {
227                                        handler(event.into());
228                                    }
229                                }
230                            }
231                        },
232                        Duration::ZERO,
233                    );
234                },
235            ))
236        } else {
237            None
238        };
239
240        sendwrap_fn!(once move || {
241            remove_click_listener();
242            remove_pointer_listener();
243            if let Some(f) = remove_blur_listener {
244                f();
245            }
246        })
247    }
248}
249
250/// Options for [`on_click_outside_with_options`].
251#[derive(Clone, DefaultBuilder)]
252#[cfg_attr(feature = "ssr", allow(dead_code))]
253pub struct OnClickOutsideOptions {
254    /// List of elementss that should not trigger the callback. Defaults to `[]`.
255    #[builder(skip)]
256    ignore: ElementsMaybeSignal<web_sys::EventTarget>,
257
258    /// Use capturing phase for internal event listener. Defaults to `true`.
259    capture: bool,
260
261    /// Run callback if focus moves to an iframe. Defaults to `false`.
262    detect_iframes: bool,
263}
264
265impl Default for OnClickOutsideOptions {
266    fn default() -> Self {
267        Self {
268            ignore: Vec::<web_sys::EventTarget>::new().into_elements_maybe_signal(),
269            capture: true,
270            detect_iframes: false,
271        }
272    }
273}
274
275impl OnClickOutsideOptions {
276    /// List of elements that should not trigger the callback. Defaults to `[]`.
277    #[cfg_attr(feature = "ssr", allow(dead_code))]
278    pub fn ignore<M: ?Sized>(
279        self,
280        ignore: impl IntoElementsMaybeSignal<web_sys::EventTarget, M>,
281    ) -> Self {
282        Self {
283            ignore: ignore.into_elements_maybe_signal(),
284            ..self
285        }
286    }
287}