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/// > Make sure you follow the [instructions in Server-Side Rendering](https://leptos-use.rs/server_side_rendering.html).
87///
88/// On the server this amounts to a no-op.
89pub fn on_click_outside<El, M, F>(target: El, handler: F) -> impl FnOnce() + Clone + Send + Sync
90where
91    El: IntoElementMaybeSignal<web_sys::EventTarget, M>,
92    F: FnMut(web_sys::Event) + Clone + 'static,
93{
94    on_click_outside_with_options(target, handler, OnClickOutsideOptions::default())
95}
96
97/// Version of `on_click_outside` that takes an `OnClickOutsideOptions`. See `on_click_outside` for more details.
98#[cfg_attr(feature = "ssr", allow(unused_variables))]
99pub fn on_click_outside_with_options<El, M, F>(
100    target: El,
101    handler: F,
102    options: OnClickOutsideOptions,
103) -> impl FnOnce() + Clone + Send + Sync
104where
105    El: IntoElementMaybeSignal<web_sys::EventTarget, M>,
106    F: FnMut(web_sys::Event) + Clone + 'static,
107{
108    #[cfg(feature = "ssr")]
109    {
110        || {}
111    }
112
113    #[cfg(not(feature = "ssr"))]
114    {
115        let OnClickOutsideOptions {
116            ignore,
117            capture,
118            detect_iframes,
119        } = options;
120
121        // Fixes: https://github.com/vueuse/vueuse/issues/1520
122        // How it works: https://stackoverflow.com/a/39712411
123        if *IS_IOS
124            && let Ok(mut ios_workaround) = IOS_WORKAROUND.write()
125            && !*ios_workaround
126        {
127            *ios_workaround = true;
128            if let Some(body) = document().body() {
129                let children = body.children();
130                for i in 0..children.length() {
131                    let _ = children
132                        .get_with_index(i)
133                        .expect("checked index")
134                        .add_event_listener_with_callback("click", &js_sys::Function::default());
135                }
136            }
137        }
138
139        let should_listen = Rc::new(Cell::new(true));
140
141        let should_ignore = move |event: &web_sys::UiEvent| {
142            let ignore = ignore.get_untracked();
143
144            ignore.into_iter().flatten().any(|element| {
145                event_target::<web_sys::EventTarget>(event) == element
146                    || event.composed_path().includes(element.as_ref(), 0)
147            })
148        };
149
150        let target = target.into_element_maybe_signal();
151
152        let listener = {
153            let should_listen = Rc::clone(&should_listen);
154            let mut handler = handler.clone();
155
156            move |event: web_sys::UiEvent| {
157                if let Some(el) = target.get_untracked() {
158                    if el == event_target(&event) || event.composed_path().includes(el.as_ref(), 0)
159                    {
160                        return;
161                    }
162
163                    if event.detail() == 0 {
164                        should_listen.set(!should_ignore(&event));
165                    }
166
167                    if !should_listen.get() {
168                        should_listen.set(true);
169                        return;
170                    }
171
172                    #[cfg(debug_assertions)]
173                    let _z = leptos::reactive::diagnostics::SpecialNonReactiveZone::enter();
174
175                    handler(event.into());
176                }
177            }
178        };
179
180        let remove_click_listener = {
181            let mut listener = listener.clone();
182
183            use_event_listener_with_options::<_, web_sys::Window, _, _>(
184                window(),
185                click,
186                move |event| listener(event.into()),
187                UseEventListenerOptions::default()
188                    .passive(true)
189                    .capture(capture),
190            )
191        };
192
193        let remove_pointer_listener = {
194            let should_listen = Rc::clone(&should_listen);
195
196            use_event_listener_with_options::<_, web_sys::Window, _, _>(
197                window(),
198                pointerdown,
199                move |event| {
200                    if let Some(el) = target.get_untracked() {
201                        should_listen
202                            .set(!event.composed_path().includes(&el, 0) && !should_ignore(&event));
203                    }
204                },
205                UseEventListenerOptions::default().passive(true),
206            )
207        };
208
209        let remove_blur_listener = if detect_iframes {
210            Some(use_event_listener::<_, web_sys::Window, _, _>(
211                window(),
212                blur,
213                move |event| {
214                    let mut handler = handler.clone();
215
216                    let _ = set_timeout_with_handle(
217                        move || {
218                            if let Some(el) = target.get_untracked()
219                                && let Some(active_element) = document().active_element()
220                                && active_element.tag_name() == "IFRAME"
221                                && !el
222                                    .unchecked_into::<web_sys::Node>()
223                                    .contains(Some(&active_element.into()))
224                            {
225                                handler(event.into());
226                            }
227                        },
228                        Duration::ZERO,
229                    );
230                },
231            ))
232        } else {
233            None
234        };
235
236        sendwrap_fn!(once move || {
237            remove_click_listener();
238            remove_pointer_listener();
239            if let Some(f) = remove_blur_listener {
240                f();
241            }
242        })
243    }
244}
245
246/// Options for [`on_click_outside_with_options`].
247#[derive(Clone, DefaultBuilder)]
248#[cfg_attr(feature = "ssr", allow(dead_code))]
249pub struct OnClickOutsideOptions {
250    /// List of elementss that should not trigger the callback. Defaults to `[]`.
251    #[builder(skip)]
252    ignore: ElementsMaybeSignal<web_sys::EventTarget>,
253
254    /// Use capturing phase for internal event listener. Defaults to `true`.
255    capture: bool,
256
257    /// Run callback if focus moves to an iframe. Defaults to `false`.
258    detect_iframes: bool,
259}
260
261impl Default for OnClickOutsideOptions {
262    fn default() -> Self {
263        Self {
264            ignore: Vec::<web_sys::EventTarget>::new().into_elements_maybe_signal(),
265            capture: true,
266            detect_iframes: false,
267        }
268    }
269}
270
271impl OnClickOutsideOptions {
272    /// List of elements that should not trigger the callback. Defaults to `[]`.
273    #[cfg_attr(feature = "ssr", allow(dead_code))]
274    pub fn ignore<M>(self, ignore: impl IntoElementsMaybeSignal<web_sys::EventTarget, M>) -> Self {
275        Self {
276            ignore: ignore.into_elements_maybe_signal(),
277            ..self
278        }
279    }
280}