biji_ui/components/tooltip/
tooltip.rs

1use std::time::Duration;
2
3use leptos::ev::{blur, focus, mousemove, pointerenter, pointerleave};
4use leptos::{context::Provider, leptos_dom, prelude::*};
5use leptos_dom::helpers::TimeoutHandle;
6use leptos_use::{
7    use_document, use_element_bounding, use_event_listener, UseElementBoundingReturn,
8};
9
10use crate::{
11    cn,
12    components::tooltip::context::TooltipContext,
13    utils::polygon::{get_points_from_el, make_hull, point_in_polygon},
14};
15
16#[derive(Copy, Clone)]
17pub enum Positioning {
18    Top,
19    TopStart,
20    TopEnd,
21    Right,
22    RightStart,
23    RightEnd,
24    Bottom,
25    BottomStart,
26    BottomEnd,
27    Left,
28    LeftStart,
29    LeftEnd,
30}
31
32impl Default for Positioning {
33    fn default() -> Self {
34        Positioning::Top
35    }
36}
37
38impl Positioning {
39    pub fn calculate_position_style(
40        self,
41        top: f64,
42        left: f64,
43        width: f64,
44        height: f64,
45        content_height: f64,
46        content_width: f64,
47        arrow_size: f64,
48    ) -> String {
49        let position = self.calculate_position(
50            top,
51            left,
52            width,
53            height,
54            content_height,
55            content_width,
56            arrow_size,
57        );
58        let arrow_position = self.calculate_arrow_position(top, left, width, height, arrow_size);
59        format!(
60            "position: fixed; top: {}px; left: {}px; --biji-tooltip-arrow-top: {}px; --biji-tooltip-arrow-left: {}px; --biji-tooltip-arrow-rotation: {}deg;",
61            position.0, position.1, arrow_position.0, arrow_position.1, arrow_position.2
62        )
63    }
64
65    pub fn calculate_position(
66        self,
67        top: f64,
68        left: f64,
69        width: f64,
70        height: f64,
71        content_height: f64,
72        content_width: f64,
73        arrow_size: f64,
74    ) -> (f64, f64) {
75        match self {
76            Positioning::Top => {
77                let top = top - content_height - arrow_size;
78                let left = left + (width / 2.0) - (content_width / 2.0);
79                (top, left)
80            }
81            Positioning::TopStart => {
82                let top = top - content_height - arrow_size;
83                (top, left)
84            }
85            Positioning::TopEnd => {
86                let top = top - content_height - arrow_size;
87                let left = left + width - content_width;
88                (top, left)
89            }
90            Positioning::Right => {
91                let top = top + (height / 2.0) - (content_height / 2.0);
92                let left = left + width + arrow_size;
93                (top, left)
94            }
95            Positioning::RightStart => {
96                let left = left + width + arrow_size;
97                (top, left)
98            }
99            Positioning::RightEnd => {
100                let top = top + height - content_height;
101                let left = left + width + arrow_size;
102                (top, left)
103            }
104            Positioning::Bottom => {
105                let top = top + height + arrow_size;
106                let left = left + (width / 2.0) - (content_width / 2.0);
107                (top, left)
108            }
109            Positioning::BottomStart => {
110                let top = top + height + arrow_size;
111                (top, left)
112            }
113            Positioning::BottomEnd => {
114                let top = top + height + arrow_size;
115                let left = left + width - content_width;
116                (top, left)
117            }
118            Positioning::Left => {
119                let top = top + (height / 2.0) - (content_height / 2.0);
120                let left = left - content_width - arrow_size;
121                (top, left)
122            }
123            Positioning::LeftStart => {
124                let left = left - content_width - arrow_size;
125                (top, left)
126            }
127            Positioning::LeftEnd => {
128                let left = left - content_width - arrow_size;
129                let top = top + height - content_height;
130                (top, left)
131            }
132        }
133    }
134
135    pub fn calculate_arrow_position(
136        self,
137        top: f64,
138        left: f64,
139        width: f64,
140        height: f64,
141        arrow_size: f64,
142    ) -> (f64, f64, i32) {
143        match self {
144            Positioning::Top => {
145                let top = top - arrow_size - (arrow_size / 2.0);
146                let left = left + (width / 2.0) - (arrow_size / 2.0);
147                (top, left, 225)
148            }
149            Positioning::TopStart => {
150                let top = top - arrow_size - (arrow_size / 2.0);
151                let left = left + (width / 2.0) - (arrow_size / 2.0);
152                (top, left, 225)
153            }
154            Positioning::TopEnd => {
155                let top = top - arrow_size - (arrow_size / 2.0);
156                let left = left + (width / 2.0) - (arrow_size / 2.0);
157                (top, left, 225)
158            }
159            Positioning::Right => {
160                let top = top + (height / 2.0) - (arrow_size / 2.0);
161                let left = left + width + (arrow_size / 2.0);
162                (top, left, 315)
163            }
164            Positioning::RightStart => {
165                let top = top + (height / 2.0) - (arrow_size / 2.0);
166                let left = left + width + (arrow_size / 2.0);
167                (top, left, 315)
168            }
169            Positioning::RightEnd => {
170                let top = top + (height / 2.0) - (arrow_size / 2.0);
171                let left = left + width + (arrow_size / 2.0);
172                (top, left, 315)
173            }
174            Positioning::Bottom => {
175                let top = top + height + arrow_size - (arrow_size / 2.0);
176                let left = left + (width / 2.0) - (arrow_size / 2.0);
177                (top, left, 45)
178            }
179            Positioning::BottomStart => {
180                let top = top + height + arrow_size - (arrow_size / 2.0);
181                let left = left + (width / 2.0) - (arrow_size / 2.0);
182                (top, left, 45)
183            }
184            Positioning::BottomEnd => {
185                let top = top + height + arrow_size - (arrow_size / 2.0);
186                let left = left + (width / 2.0) - (arrow_size / 2.0);
187                (top, left, 45)
188            }
189            Positioning::Left => {
190                let top = top + (height / 2.0) - (arrow_size / 2.0);
191                let left = left - arrow_size - (arrow_size / 2.0);
192                (top, left, 135)
193            }
194            Positioning::LeftStart => {
195                let top = top + (height / 2.0) - (arrow_size / 2.0);
196                let left = left - arrow_size - (arrow_size / 2.0);
197                (top, left, 135)
198            }
199            Positioning::LeftEnd => {
200                let top = top + (height / 2.0) - (arrow_size / 2.0);
201                let left = left - arrow_size - (arrow_size / 2.0);
202                (top, left, 135)
203            }
204        }
205    }
206}
207
208#[component]
209pub fn Trigger(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
210    let tooltip_ctx = expect_context::<TooltipContext>();
211
212    let trigger_ref = tooltip_ctx.trigger_ref;
213
214    view! {
215        <TriggerEvents>
216            <button node_ref={trigger_ref} class={class}>
217                {children()}
218            </button>
219        </TriggerEvents>
220    }
221}
222
223#[component]
224pub fn TriggerEvents(children: Children) -> impl IntoView {
225    let tooltip_ctx = expect_context::<TooltipContext>();
226
227    let UseElementBoundingReturn {
228        top: trigger_top,
229        left: trigger_left,
230        bottom: trigger_bottom,
231        right: trigger_right,
232        width: trigger_width,
233        height: trigger_height,
234        ..
235    } = use_element_bounding(tooltip_ctx.trigger_ref);
236
237    let UseElementBoundingReturn {
238        width: content_width,
239        height: content_height,
240        ..
241    } = use_element_bounding(tooltip_ctx.content_ref);
242
243    let polygon_elements = move || {
244        let mut trigger_points =
245            get_points_from_el(&(trigger_top, trigger_right, trigger_bottom, trigger_left));
246
247        let content_pos = tooltip_ctx.positioning.calculate_position(
248            trigger_top.get(),
249            trigger_left.get(),
250            trigger_width.get(),
251            trigger_height.get(),
252            content_height.get(),
253            content_width.get(),
254            tooltip_ctx.arrow_size as f64,
255        );
256
257        let mut content_points = vec![
258            (content_pos.1, content_pos.0),
259            (content_pos.1 + content_width.get(), content_pos.0),
260            (
261                content_pos.1 + content_width.get(),
262                content_pos.0 + content_height.get(),
263            ),
264            (content_pos.1, content_pos.0 + content_height.get()),
265        ];
266
267        trigger_points.append(&mut content_points);
268
269        trigger_points
270    };
271
272    let polygon = move || make_hull(&polygon_elements());
273
274    let _ = use_event_listener(tooltip_ctx.trigger_ref, pointerenter, move |_| {
275        tooltip_ctx.pointer_inside_trigger.set(true);
276        tooltip_ctx.open();
277    });
278
279    let _ = use_event_listener(tooltip_ctx.trigger_ref, pointerleave, move |_| {
280        tooltip_ctx.pointer_inside_trigger.set(false);
281    });
282
283    let _ = use_event_listener(tooltip_ctx.content_ref, pointerenter, move |_| {
284        tooltip_ctx.pointer_inside_content.set(true);
285    });
286
287    let _ = use_event_listener(tooltip_ctx.content_ref, pointerleave, move |_| {
288        tooltip_ctx.pointer_inside_content.set(false);
289    });
290
291    let _ = use_event_listener(use_document(), mousemove, move |e| {
292        if tooltip_ctx.pointer_inside_content.get()
293            || tooltip_ctx.pointer_inside_trigger.get()
294            || point_in_polygon((e.x() as f64, e.y() as f64), &polygon())
295        {
296            return;
297        }
298        if tooltip_ctx.open.get() {
299            tooltip_ctx.close();
300        }
301    });
302
303    let _ = use_event_listener(tooltip_ctx.trigger_ref, focus, move |_| {
304        tooltip_ctx.open();
305    });
306
307    let _ = use_event_listener(tooltip_ctx.trigger_ref, blur, move |_| {
308        if tooltip_ctx.open.get() {
309            tooltip_ctx.close();
310        }
311    });
312
313    children()
314}
315
316#[component]
317pub fn Root(
318    #[prop(into, optional)] class: String,
319    children: Children,
320    /// The timeout after which the component will be unmounted if `when == false`
321    #[prop(default = Duration::from_millis(200))]
322    hide_delay: Duration,
323    #[prop(default = Positioning::default())] positioning: Positioning,
324) -> impl IntoView {
325    let ctx = TooltipContext {
326        hide_delay,
327        positioning,
328        ..TooltipContext::default()
329    };
330
331    view! {
332        <Provider value={ctx}>
333            <div class={class}>{children()}</div>
334        </Provider>
335    }
336}
337
338#[component]
339pub fn Content(
340    children: ChildrenFn,
341    /// Optional CSS class to apply to both show and hide classes
342    #[prop(into, optional)]
343    class: String,
344    /// Optional CSS class to apply if `when == true`
345    #[prop(into, optional)]
346    show_class: String,
347    /// Optional CSS class to apply if `when == false`
348    #[prop(into, optional)]
349    hide_class: String,
350) -> impl IntoView {
351    let show_class = cn!(class, show_class);
352    let hide_class = cn!(class, hide_class);
353
354    let tooltip_ctx = expect_context::<TooltipContext>();
355
356    let content_ref = tooltip_ctx.content_ref;
357
358    let hide_delay = tooltip_ctx.hide_delay;
359    let when = tooltip_ctx.open;
360
361    let show_handle: StoredValue<Option<TimeoutHandle>> = StoredValue::new(None);
362    let hide_handle: StoredValue<Option<TimeoutHandle>> = StoredValue::new(None);
363    let cls = RwSignal::new(if when.get_untracked() {
364        show_class.clone()
365    } else {
366        hide_class.clone()
367    });
368    let show = RwSignal::new(when.get_untracked());
369
370    let UseElementBoundingReturn {
371        width: content_width,
372        height: content_height,
373        ..
374    } = use_element_bounding(tooltip_ctx.content_ref);
375
376    let UseElementBoundingReturn {
377        top,
378        left,
379        width,
380        height,
381        ..
382    } = use_element_bounding(tooltip_ctx.trigger_ref);
383
384    let style = move || {
385        tooltip_ctx.positioning.calculate_position_style(
386            *top.read(),
387            *left.read(),
388            *width.read(),
389            *height.read(),
390            *content_height.read(),
391            *content_width.read(),
392            tooltip_ctx.arrow_size as f64,
393        )
394    };
395
396    let eff = RenderEffect::new(move |_| {
397        let show_class = show_class.clone();
398        if when.get() {
399            // clear any possibly active timer
400            if let Some(h) = show_handle.get_value() {
401                h.clear();
402            }
403            if let Some(h) = hide_handle.get_value() {
404                h.clear();
405            }
406
407            let h = leptos_dom::helpers::set_timeout_with_handle(
408                move || cls.set(show_class.clone()),
409                Duration::from_millis(1),
410            )
411            .expect("set timeout in AnimatedShow");
412            show_handle.set_value(Some(h));
413
414            cls.set(hide_class.clone());
415            show.set(true);
416        } else {
417            cls.set(hide_class.clone());
418
419            let h =
420                leptos_dom::helpers::set_timeout_with_handle(move || show.set(false), hide_delay)
421                    .expect("set timeout in AnimatedShow");
422            hide_handle.set_value(Some(h));
423        }
424    });
425
426    on_cleanup(move || {
427        if let Some(Some(h)) = show_handle.try_get_value() {
428            h.clear();
429        }
430        if let Some(Some(h)) = hide_handle.try_get_value() {
431            h.clear();
432        }
433        drop(eff);
434    });
435
436    view! {
437        <Show when={move || show.get()} fallback={|| ()}>
438            <div node_ref={content_ref} class={move || cls.get()} style={style}>
439                {children()}
440            </div>
441        </Show>
442    }
443}
444
445#[component]
446pub fn Arrow(#[prop(into, optional)] class: String) -> impl IntoView {
447    let tooltip_ctx = expect_context::<TooltipContext>();
448    view! {
449        <div
450            class={class}
451            style={move || {
452                format!(
453                    "position: fixed; top: var(--biji-tooltip-arrow-top); left: var(--biji-tooltip-arrow-left); height: {}px; width: {}px; background-color: inherit; transform: rotate(var(--biji-tooltip-arrow-rotation));",
454                    tooltip_ctx.arrow_size,
455                    tooltip_ctx.arrow_size,
456                )
457            }}
458        >
459        </div>
460    }
461}