Skip to main content

orbital_base_components/overlay/
visibility.rs

1use leptos::{
2    either::Either,
3    ev::{self, on},
4    html,
5    leptos_dom::helpers::TimeoutHandle,
6    prelude::*,
7    tachys::html::node_ref::node_ref,
8};
9use std::time::Duration;
10
11use super::dom_events::on_click_outside;
12use super::element_ref::AnyElement;
13use super::trigger::OverlayTriggerType;
14
15pub const HOVER_HIDE_DELAY_MS: u64 = 100;
16
17pub fn should_schedule_hover_hide(trigger_type: OverlayTriggerType) -> bool {
18    trigger_type.uses_hover()
19}
20
21pub fn should_toggle_on_click(trigger_type: OverlayTriggerType) -> bool {
22    trigger_type.uses_click()
23}
24
25#[derive(Clone, Default)]
26pub struct OverlayLifecycle {
27    pub on_open: Option<Callback<()>>,
28    pub on_close: Option<Callback<()>>,
29}
30
31#[derive(Clone, Copy, Debug)]
32pub struct OverlayHoverDelays {
33    pub show_delay_ms: u64,
34    pub hide_delay_ms: u64,
35}
36
37impl Default for OverlayHoverDelays {
38    fn default() -> Self {
39        Self {
40            show_delay_ms: 0,
41            hide_delay_ms: HOVER_HIDE_DELAY_MS,
42        }
43    }
44}
45
46#[derive(Clone, Copy)]
47pub struct UseOverlayVisibility {
48    pub is_show: RwSignal<bool>,
49    trigger_ref: NodeRef<AnyElement>,
50    panel_ref: NodeRef<html::Div>,
51    show_timer: StoredValue<Option<TimeoutHandle>>,
52    hide_timer: StoredValue<Option<TimeoutHandle>>,
53    trigger_type: OverlayTriggerType,
54    delays: OverlayHoverDelays,
55}
56
57impl UseOverlayVisibility {
58    pub fn new(
59        trigger_type: OverlayTriggerType,
60        panel_ref: NodeRef<html::Div>,
61        lifecycle: Option<OverlayLifecycle>,
62        delays: OverlayHoverDelays,
63    ) -> Self {
64        let is_show = RwSignal::new(false);
65        let trigger_ref = NodeRef::<AnyElement>::new();
66        let show_timer = StoredValue::new(None::<TimeoutHandle>);
67        let hide_timer = StoredValue::new(None::<TimeoutHandle>);
68
69        if trigger_type.uses_click() {
70            let is_show = is_show;
71            on_click_outside(
72                move || {
73                    if !is_show.get_untracked() {
74                        return None;
75                    }
76                    let mut els = Vec::new();
77                    if let Some(trigger_el) = trigger_ref.try_get_untracked().flatten() {
78                        els.push(trigger_el);
79                    }
80                    if let Some(panel_el) = panel_ref.try_get_untracked().flatten() {
81                        els.push(panel_el.into());
82                    }
83                    Some(els)
84                },
85                move || is_show.set(false),
86            );
87        }
88
89        if let Some(lifecycle) = lifecycle {
90            Effect::watch(
91                move || is_show.get(),
92                move |shown, prev, _| {
93                    if prev == Some(shown) {
94                        return;
95                    }
96                    if *shown {
97                        if let Some(on_open) = lifecycle.on_open {
98                            on_open.run(());
99                        }
100                    } else if let Some(on_close) = lifecycle.on_close {
101                        on_close.run(());
102                    }
103                },
104                false,
105            );
106        }
107
108        #[cfg(not(feature = "ssr"))]
109        Owner::on_cleanup(move || {
110            show_timer.update_value(|handle| {
111                if let Some(handle) = handle.take() {
112                    handle.clear();
113                }
114            });
115            hide_timer.update_value(|handle| {
116                if let Some(handle) = handle.take() {
117                    handle.clear();
118                }
119            });
120        });
121
122        Self {
123            is_show,
124            trigger_ref,
125            panel_ref,
126            show_timer,
127            hide_timer,
128            trigger_type,
129            delays,
130        }
131    }
132
133    pub fn panel_ref(&self) -> NodeRef<html::Div> {
134        self.panel_ref
135    }
136
137    pub fn on_mouse_enter(&self, _: ev::MouseEvent) {
138        if !self.trigger_type.uses_hover() {
139            return;
140        }
141        self.clear_hide_timer();
142        if self.delays.show_delay_ms == 0 {
143            self.is_show.set(true);
144        } else {
145            self.schedule_show();
146        }
147    }
148
149    pub fn on_mouse_leave(&self, _: ev::MouseEvent) {
150        if !should_schedule_hover_hide(self.trigger_type) {
151            return;
152        }
153        self.clear_show_timer();
154        self.schedule_hide();
155    }
156
157    pub fn attach_trigger<T>(&self, trigger: T, trigger_type: OverlayTriggerType) -> impl IntoView
158    where
159        T: AddAnyAttr + IntoView + Send + 'static,
160    {
161        let trigger = trigger.add_any_attr(node_ref(self.trigger_ref));
162        let with_focus = trigger
163            .add_any_attr(on(ev::focusin, {
164                let vis = *self;
165                move |e| vis.on_focus_in(e)
166            }))
167            .add_any_attr(on(ev::focusout, {
168                let vis = *self;
169                move |e| vis.on_focus_out(e)
170            }));
171        match trigger_type {
172            OverlayTriggerType::Click => Either::Left(with_focus.add_any_attr(on(ev::click, {
173                let is_show = self.is_show;
174                move |_| is_show.update(|show| *show = !*show)
175            }))),
176            OverlayTriggerType::Hover => Either::Right(
177                with_focus
178                    .add_any_attr(on(ev::mouseenter, {
179                        let vis = *self;
180                        move |e| vis.on_mouse_enter(e)
181                    }))
182                    .add_any_attr(on(ev::mouseleave, {
183                        let vis = *self;
184                        move |e| vis.on_mouse_leave(e)
185                    })),
186            ),
187        }
188    }
189
190    fn on_focus_in(&self, _: ev::FocusEvent) {
191        if self.trigger_type.uses_click() {
192            return;
193        }
194        self.clear_hide_timer();
195        if self.delays.show_delay_ms == 0 {
196            self.is_show.set(true);
197        } else {
198            self.schedule_show();
199        }
200    }
201
202    fn on_focus_out(&self, _: ev::FocusEvent) {
203        if self.trigger_type.uses_click() {
204            return;
205        }
206        self.clear_show_timer();
207        if should_schedule_hover_hide(self.trigger_type) {
208            self.schedule_hide();
209        } else {
210            self.is_show.set(false);
211        }
212    }
213
214    fn clear_show_timer(&self) {
215        self.show_timer.update_value(|handle| {
216            if let Some(handle) = handle.take() {
217                handle.clear();
218            }
219        });
220    }
221
222    fn clear_hide_timer(&self) {
223        self.hide_timer.update_value(|handle| {
224            if let Some(handle) = handle.take() {
225                handle.clear();
226            }
227        });
228    }
229
230    fn schedule_show(&self) {
231        let is_show = self.is_show;
232        let delay_ms = self.delays.show_delay_ms;
233        self.show_timer.update_value(|handle| {
234            if let Some(handle) = handle.take() {
235                handle.clear();
236            }
237            *handle =
238                set_timeout_with_handle(move || is_show.set(true), Duration::from_millis(delay_ms))
239                    .ok();
240        });
241    }
242
243    fn schedule_hide(&self) {
244        let is_show = self.is_show;
245        let delay_ms = self.delays.hide_delay_ms;
246        self.hide_timer.update_value(|handle| {
247            if let Some(handle) = handle.take() {
248                handle.clear();
249            }
250            *handle = set_timeout_with_handle(
251                move || is_show.set(false),
252                Duration::from_millis(delay_ms),
253            )
254            .ok();
255        });
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn hover_delay_is_100ms() {
265        assert_eq!(HOVER_HIDE_DELAY_MS, 100);
266    }
267
268    #[test]
269    fn click_mode_skips_hover_hide() {
270        assert!(!should_schedule_hover_hide(OverlayTriggerType::Click));
271        assert!(should_schedule_hover_hide(OverlayTriggerType::Hover));
272    }
273
274    #[test]
275    fn click_mode_toggles() {
276        assert!(should_toggle_on_click(OverlayTriggerType::Click));
277        assert!(!should_toggle_on_click(OverlayTriggerType::Hover));
278    }
279}