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::{
14        polygon::{get_points_from_el, make_hull, point_in_polygon},
15        positioning::Positioning,
16    },
17};
18
19#[component]
20pub fn Trigger(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
21    let tooltip_ctx = expect_context::<TooltipContext>();
22
23    let trigger_ref = tooltip_ctx.trigger_ref;
24
25    view! {
26        <TriggerEvents>
27            <button node_ref={trigger_ref} class={class}>
28                {children()}
29            </button>
30        </TriggerEvents>
31    }
32}
33
34#[component]
35pub fn TriggerEvents(children: Children) -> impl IntoView {
36    let tooltip_ctx = expect_context::<TooltipContext>();
37
38    let UseElementBoundingReturn {
39        top: trigger_top,
40        left: trigger_left,
41        bottom: trigger_bottom,
42        right: trigger_right,
43        width: trigger_width,
44        height: trigger_height,
45        ..
46    } = use_element_bounding(tooltip_ctx.trigger_ref);
47
48    let UseElementBoundingReturn {
49        width: content_width,
50        height: content_height,
51        ..
52    } = use_element_bounding(tooltip_ctx.content_ref);
53
54    let polygon_elements = move || {
55        let mut trigger_points =
56            get_points_from_el(&(trigger_top, trigger_right, trigger_bottom, trigger_left));
57
58        let content_pos = tooltip_ctx.positioning.calculate_position(
59            trigger_top.get(),
60            trigger_left.get(),
61            trigger_width.get(),
62            trigger_height.get(),
63            content_height.get(),
64            content_width.get(),
65            tooltip_ctx.arrow_size as f64,
66        );
67
68        let mut content_points = vec![
69            (content_pos.1, content_pos.0),
70            (content_pos.1 + content_width.get(), content_pos.0),
71            (
72                content_pos.1 + content_width.get(),
73                content_pos.0 + content_height.get(),
74            ),
75            (content_pos.1, content_pos.0 + content_height.get()),
76        ];
77
78        trigger_points.append(&mut content_points);
79
80        trigger_points
81    };
82
83    let polygon = move || make_hull(&polygon_elements());
84
85    let _ = use_event_listener(tooltip_ctx.trigger_ref, pointerenter, move |_| {
86        tooltip_ctx.pointer_inside_trigger.set(true);
87        tooltip_ctx.open();
88    });
89
90    let _ = use_event_listener(tooltip_ctx.trigger_ref, pointerleave, move |_| {
91        tooltip_ctx.pointer_inside_trigger.set(false);
92    });
93
94    let _ = use_event_listener(tooltip_ctx.content_ref, pointerenter, move |_| {
95        tooltip_ctx.pointer_inside_content.set(true);
96    });
97
98    let _ = use_event_listener(tooltip_ctx.content_ref, pointerleave, move |_| {
99        tooltip_ctx.pointer_inside_content.set(false);
100    });
101
102    let _ = use_event_listener(use_document(), mousemove, move |e| {
103        if tooltip_ctx.pointer_inside_content.get()
104            || tooltip_ctx.pointer_inside_trigger.get()
105            || point_in_polygon((e.x() as f64, e.y() as f64), &polygon())
106        {
107            return;
108        }
109        if tooltip_ctx.open.get() {
110            tooltip_ctx.close();
111        }
112    });
113
114    let _ = use_event_listener(tooltip_ctx.trigger_ref, focus, move |_| {
115        tooltip_ctx.open();
116    });
117
118    let _ = use_event_listener(tooltip_ctx.trigger_ref, blur, move |_| {
119        if tooltip_ctx.open.get() {
120            tooltip_ctx.close();
121        }
122    });
123
124    children()
125}
126
127#[component]
128pub fn Root(
129    #[prop(into, optional)] class: String,
130    children: Children,
131    /// The timeout after which the component will be unmounted if `when == false`
132    #[prop(default = Duration::from_millis(200))]
133    hide_delay: Duration,
134    #[prop(default = Positioning::default())] positioning: Positioning,
135) -> impl IntoView {
136    let ctx = TooltipContext {
137        hide_delay,
138        positioning,
139        ..TooltipContext::default()
140    };
141
142    view! {
143        <Provider value={ctx}>
144            <div class={class}>{children()}</div>
145        </Provider>
146    }
147}
148
149#[component]
150pub fn Content(
151    children: ChildrenFn,
152    /// Optional CSS class to apply to both show and hide classes
153    #[prop(into, optional)]
154    class: String,
155    /// Optional CSS class to apply if `when == true`
156    #[prop(into, optional)]
157    show_class: String,
158    /// Optional CSS class to apply if `when == false`
159    #[prop(into, optional)]
160    hide_class: String,
161) -> impl IntoView {
162    let show_class = cn!(class, show_class);
163    let hide_class = cn!(class, hide_class);
164
165    let tooltip_ctx = expect_context::<TooltipContext>();
166
167    let content_ref = tooltip_ctx.content_ref;
168
169    let hide_delay = tooltip_ctx.hide_delay;
170    let when = tooltip_ctx.open;
171
172    let show_handle: StoredValue<Option<TimeoutHandle>> = StoredValue::new(None);
173    let hide_handle: StoredValue<Option<TimeoutHandle>> = StoredValue::new(None);
174    let cls = RwSignal::new(if when.get_untracked() {
175        show_class.clone()
176    } else {
177        hide_class.clone()
178    });
179    let show = RwSignal::new(when.get_untracked());
180
181    let UseElementBoundingReturn {
182        width: content_width,
183        height: content_height,
184        ..
185    } = use_element_bounding(tooltip_ctx.content_ref);
186
187    let UseElementBoundingReturn {
188        top,
189        left,
190        width,
191        height,
192        ..
193    } = use_element_bounding(tooltip_ctx.trigger_ref);
194
195    let style = move || {
196        tooltip_ctx.positioning.calculate_position_style(
197            *top.read(),
198            *left.read(),
199            *width.read(),
200            *height.read(),
201            *content_height.read(),
202            *content_width.read(),
203            tooltip_ctx.arrow_size as f64,
204            tooltip_ctx.arrow_size as f64,
205        )
206    };
207
208    let eff = RenderEffect::new(move |_| {
209        let show_class = show_class.clone();
210        if when.get() {
211            // clear any possibly active timer
212            if let Some(h) = show_handle.get_value() {
213                h.clear();
214            }
215            if let Some(h) = hide_handle.get_value() {
216                h.clear();
217            }
218
219            let h = leptos_dom::helpers::set_timeout_with_handle(
220                move || cls.set(show_class.clone()),
221                Duration::from_millis(1),
222            )
223            .expect("set timeout in AnimatedShow");
224            show_handle.set_value(Some(h));
225
226            cls.set(hide_class.clone());
227            show.set(true);
228        } else {
229            cls.set(hide_class.clone());
230
231            let h =
232                leptos_dom::helpers::set_timeout_with_handle(move || show.set(false), hide_delay)
233                    .expect("set timeout in AnimatedShow");
234            hide_handle.set_value(Some(h));
235        }
236    });
237
238    on_cleanup(move || {
239        if let Some(Some(h)) = show_handle.try_get_value() {
240            h.clear();
241        }
242        if let Some(Some(h)) = hide_handle.try_get_value() {
243            h.clear();
244        }
245        drop(eff);
246    });
247
248    view! {
249        <Show when={move || show.get()} fallback={|| ()}>
250            <div node_ref={content_ref} class={move || cls.get()} style={style}>
251                {children()}
252            </div>
253        </Show>
254    }
255}
256
257#[component]
258pub fn Arrow(#[prop(into, optional)] class: String) -> impl IntoView {
259    let tooltip_ctx = expect_context::<TooltipContext>();
260    view! {
261        <div
262            class={class}
263            style={move || {
264                format!(
265                    "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));",
266                    tooltip_ctx.arrow_size,
267                    tooltip_ctx.arrow_size,
268                )
269            }}
270        ></div>
271    }
272}