orbital_base_components/overlay/
visibility.rs1use 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}