Skip to main content

dioxus_bootstrap_css/
tooltip.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2
3use dioxus::prelude::*;
4use gloo_timers::future::TimeoutFuture;
5
6use crate::overlay::{
7    OverlayOffset, OverlayPlacement, OverlayPosition, OverlayRect, calculate_overlay_position,
8};
9
10static NEXT_TOOLTIP_ID: AtomicUsize = AtomicUsize::new(1);
11static NEXT_TOOLTIP_TRIGGER_ID: AtomicUsize = AtomicUsize::new(1);
12
13/// Tooltip placement.
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub enum TooltipPlacement {
16    /// Choose the first fallback placement that fits the viewport.
17    Auto,
18    /// Place tooltip above the trigger.
19    #[default]
20    Top,
21    /// Place tooltip below the trigger.
22    Bottom,
23    /// Place tooltip before the trigger in the inline axis.
24    Start,
25    /// Place tooltip after the trigger in the inline axis.
26    End,
27}
28
29impl TooltipPlacement {
30    fn class(self) -> &'static str {
31        match self {
32            TooltipPlacement::Auto | TooltipPlacement::Top => "bs-tooltip-top",
33            TooltipPlacement::Bottom => "bs-tooltip-bottom",
34            TooltipPlacement::Start => "bs-tooltip-start",
35            TooltipPlacement::End => "bs-tooltip-end",
36        }
37    }
38
39    fn data_value(self) -> &'static str {
40        match self {
41            TooltipPlacement::Auto => "auto",
42            TooltipPlacement::Top => "top",
43            TooltipPlacement::Bottom => "bottom",
44            TooltipPlacement::Start => "start",
45            TooltipPlacement::End => "end",
46        }
47    }
48}
49
50impl From<TooltipPlacement> for OverlayPlacement {
51    fn from(value: TooltipPlacement) -> Self {
52        match value {
53            TooltipPlacement::Auto => OverlayPlacement::Auto,
54            TooltipPlacement::Top => OverlayPlacement::Top,
55            TooltipPlacement::Bottom => OverlayPlacement::Bottom,
56            TooltipPlacement::Start => OverlayPlacement::Start,
57            TooltipPlacement::End => OverlayPlacement::End,
58        }
59    }
60}
61
62impl From<OverlayPlacement> for TooltipPlacement {
63    fn from(value: OverlayPlacement) -> Self {
64        match value {
65            OverlayPlacement::Auto => TooltipPlacement::Auto,
66            OverlayPlacement::Top => TooltipPlacement::Top,
67            OverlayPlacement::Bottom => TooltipPlacement::Bottom,
68            OverlayPlacement::Start => TooltipPlacement::Start,
69            OverlayPlacement::End => TooltipPlacement::End,
70        }
71    }
72}
73
74/// Tooltip trigger set.
75///
76/// Bootstrap allows hover, focus, click, and manual trigger styles. The default
77/// matches Bootstrap's normal keyboard-friendly trigger: hover plus focus.
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub struct TooltipTriggers {
80    pub hover: bool,
81    pub focus: bool,
82    pub click: bool,
83}
84
85impl TooltipTriggers {
86    /// Hover and focus triggers.
87    pub const HOVER_FOCUS: Self = Self {
88        hover: true,
89        focus: true,
90        click: false,
91    };
92
93    /// Hover-only trigger.
94    pub const HOVER: Self = Self {
95        hover: true,
96        focus: false,
97        click: false,
98    };
99
100    /// Focus-only trigger.
101    pub const FOCUS: Self = Self {
102        hover: false,
103        focus: true,
104        click: false,
105    };
106
107    /// Click-only trigger.
108    pub const CLICK: Self = Self {
109        hover: false,
110        focus: false,
111        click: true,
112    };
113
114    /// No internal trigger; use the `open` prop.
115    pub const MANUAL: Self = Self {
116        hover: false,
117        focus: false,
118        click: false,
119    };
120}
121
122impl Default for TooltipTriggers {
123    fn default() -> Self {
124        Self::HOVER_FOCUS
125    }
126}
127
128/// Show/hide delay in milliseconds.
129#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
130pub struct TooltipDelay {
131    pub show_ms: u32,
132    pub hide_ms: u32,
133}
134
135impl TooltipDelay {
136    pub const fn new(show_ms: u32, hide_ms: u32) -> Self {
137        Self { show_ms, hide_ms }
138    }
139}
140
141#[derive(Clone)]
142struct TooltipPositioning {
143    trigger_id: String,
144    tooltip_id: String,
145    overlay_position: Signal<Option<OverlayPosition>>,
146    placement: TooltipPlacement,
147    fallback_placements: Vec<TooltipPlacement>,
148    offset: OverlayOffset,
149    boundary_padding: f64,
150}
151
152#[derive(Clone, Copy)]
153struct TooltipInteractionSignals {
154    hover_active: Signal<bool>,
155    focus_active: Signal<bool>,
156    click_active: Signal<bool>,
157}
158
159/// Wrapper helper for disabled controls that need a tooltip trigger.
160///
161/// Bootstrap documents disabled elements as non-interactive. Wrap a disabled
162/// button or input in this component so the wrapper can receive focus and
163/// pointer events while preserving the disabled child.
164#[derive(Clone, PartialEq, Props)]
165pub struct TooltipDisabledTriggerProps {
166    /// Additional CSS classes for the wrapper.
167    #[props(default)]
168    pub class: String,
169    /// Additional inline style for the wrapper.
170    #[props(default)]
171    pub style: String,
172    /// Disabled child element.
173    pub children: Element,
174}
175
176#[component]
177pub fn TooltipDisabledTrigger(props: TooltipDisabledTriggerProps) -> Element {
178    let style = if props.style.is_empty() {
179        "display: inline-block;".to_string()
180    } else {
181        format!("display: inline-block; {}", props.style)
182    };
183
184    rsx! {
185        span {
186            class: "{props.class}",
187            style,
188            tabindex: "0",
189            {props.children}
190        }
191    }
192}
193
194/// Bootstrap Tooltip component.
195///
196/// Renders a Bootstrap tooltip with Dioxus-owned trigger state and viewport-aware
197/// placement. It does not depend on Bootstrap JavaScript or Popper.js.
198///
199/// # Bootstrap HTML -> Dioxus
200///
201/// ```html
202/// <button data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip text">Hover me</button>
203/// ```
204///
205/// ```rust,no_run
206/// # use dioxus::prelude::*;
207/// # use dioxus_bootstrap_css::prelude::*;
208/// # fn _doctest() -> Element {
209/// rsx! {
210///     Tooltip { text: "Save your work", placement: TooltipPlacement::Top,
211///         Button { color: Color::Primary, "Save" }
212///     }
213///     Tooltip {
214///         text: "Click for details",
215///         trigger: TooltipTriggers::CLICK,
216///         placement: TooltipPlacement::Auto,
217///         Button { color: Color::Info, "Details" }
218///     }
219/// }
220/// # }
221/// ```
222#[derive(Clone, PartialEq, Props)]
223pub struct TooltipProps {
224    /// Tooltip text content.
225    pub text: String,
226    /// Requested placement relative to the trigger element.
227    #[props(default)]
228    pub placement: TooltipPlacement,
229    /// Fallback placements used when requested placement does not fit.
230    #[props(default)]
231    pub fallback_placements: Vec<TooltipPlacement>,
232    /// Trigger behavior. Defaults to hover plus focus.
233    #[props(default)]
234    pub trigger: TooltipTriggers,
235    /// Show/hide delay in milliseconds.
236    #[props(default)]
237    pub delay: TooltipDelay,
238    /// Controlled open state. When set, internal triggers are ignored.
239    #[props(default)]
240    pub open: Option<bool>,
241    /// Offset from the trigger.
242    #[props(default = OverlayOffset::TOOLTIP)]
243    pub offset: OverlayOffset,
244    /// Padding inside the viewport boundary.
245    #[props(default = 0.0)]
246    pub boundary_padding: f64,
247    /// Additional CSS classes for the tooltip element.
248    #[props(default)]
249    pub class: String,
250    /// Child element or elements that act as the trigger.
251    pub children: Element,
252}
253
254#[component]
255pub fn Tooltip(props: TooltipProps) -> Element {
256    let tooltip_id = use_signal(next_tooltip_id);
257    let trigger_id = use_signal(next_tooltip_trigger_id);
258    let overlay_position = use_signal(|| None::<OverlayPosition>);
259    let visible = use_signal(|| props.open.unwrap_or(false));
260    let visibility_revision = use_signal(|| 0_u64);
261    let mut hover_active = use_signal(|| false);
262    let mut focus_active = use_signal(|| false);
263    let mut click_active = use_signal(|| false);
264
265    let has_text = !props.text.is_empty();
266    let is_visible = has_text && props.open.unwrap_or(*visible.read());
267    let effective_placement = overlay_position
268        .read()
269        .as_ref()
270        .map(|position| TooltipPlacement::from(position.placement))
271        .unwrap_or(props.placement);
272    let placement_class = effective_placement.class();
273    let placement_value = effective_placement.data_value();
274    let tooltip_class = classes("tooltip fade show", placement_class, &props.class);
275    let tooltip_style = tooltip_style(*overlay_position.read());
276    let describedby = if is_visible {
277        tooltip_id.read().clone()
278    } else {
279        String::new()
280    };
281
282    let effect_has_text = has_text;
283    let positioning = TooltipPositioning {
284        trigger_id: trigger_id.read().clone(),
285        tooltip_id: tooltip_id.read().clone(),
286        overlay_position,
287        placement: props.placement,
288        fallback_placements: props.fallback_placements.clone(),
289        offset: props.offset,
290        boundary_padding: props.boundary_padding,
291    };
292    let effect_trigger_id = trigger_id.read().clone();
293    let effect_tooltip_id = tooltip_id.read().clone();
294    let mut effect_overlay_position = overlay_position;
295    use_effect(use_reactive(
296        (
297            &props.open,
298            &effect_has_text,
299            &props.placement,
300            &props.fallback_placements,
301            &props.offset,
302            &props.boundary_padding,
303        ),
304        move |(open, has_text, placement, fallback_placements, offset, boundary_padding)| {
305            let current_visible = has_text && open.unwrap_or(*visible.read());
306
307            if current_visible {
308                measure_tooltip_position(TooltipPositioning {
309                    trigger_id: effect_trigger_id.clone(),
310                    tooltip_id: effect_tooltip_id.clone(),
311                    overlay_position: effect_overlay_position,
312                    placement,
313                    fallback_placements,
314                    offset,
315                    boundary_padding,
316                });
317            } else {
318                effect_overlay_position.set(None);
319            }
320        },
321    ));
322
323    let hover_enter_positioning = positioning.clone();
324    let hover_leave_positioning = positioning.clone();
325    let focus_in_positioning = positioning.clone();
326    let focus_out_positioning = positioning.clone();
327    let click_positioning = positioning.clone();
328    let interactions = TooltipInteractionSignals {
329        hover_active,
330        focus_active,
331        click_active,
332    };
333
334    rsx! {
335        span {
336            id: "{trigger_id}",
337            class: "tooltip-wrapper",
338            style: "display: inline-block;",
339            "aria-describedby": "{describedby}",
340            onmouseenter: move |_| {
341                if props.trigger.hover && props.open.is_none() {
342                    hover_active.set(true);
343                    schedule_tooltip_visibility(
344                        visible,
345                        visibility_revision,
346                        props.open,
347                        props.trigger,
348                        props.delay,
349                        interactions,
350                        hover_enter_positioning.clone(),
351                    );
352                }
353            },
354            onmouseleave: move |_| {
355                if props.trigger.hover && props.open.is_none() {
356                    hover_active.set(false);
357                    schedule_tooltip_visibility(
358                        visible,
359                        visibility_revision,
360                        props.open,
361                        props.trigger,
362                        props.delay,
363                        interactions,
364                        hover_leave_positioning.clone(),
365                    );
366                }
367            },
368            onfocusin: move |_| {
369                if props.trigger.focus && props.open.is_none() {
370                    focus_active.set(true);
371                    schedule_tooltip_visibility(
372                        visible,
373                        visibility_revision,
374                        props.open,
375                        props.trigger,
376                        props.delay,
377                        interactions,
378                        focus_in_positioning.clone(),
379                    );
380                }
381            },
382            onfocusout: move |_| {
383                if props.trigger.focus && props.open.is_none() {
384                    focus_active.set(false);
385                    schedule_tooltip_visibility(
386                        visible,
387                        visibility_revision,
388                        props.open,
389                        props.trigger,
390                        props.delay,
391                        interactions,
392                        focus_out_positioning.clone(),
393                    );
394                }
395            },
396            onclick: move |_| {
397                if props.trigger.click && props.open.is_none() {
398                    let next_click_active = {
399                        let active = click_active.read();
400                        !*active
401                    };
402                    click_active.set(next_click_active);
403                    schedule_tooltip_visibility(
404                        visible,
405                        visibility_revision,
406                        props.open,
407                        props.trigger,
408                        props.delay,
409                        interactions,
410                        click_positioning.clone(),
411                    );
412                }
413            },
414
415            {props.children}
416
417            if is_visible {
418                div {
419                    id: "{tooltip_id}",
420                    class: "{tooltip_class}",
421                    role: "tooltip",
422                    "data-popper-placement": "{placement_value}",
423                    style: "{tooltip_style}",
424                    div { class: "tooltip-arrow" }
425                    div { class: "tooltip-inner", "{props.text}" }
426                }
427            }
428        }
429    }
430}
431
432fn next_tooltip_id() -> String {
433    let id = NEXT_TOOLTIP_ID.fetch_add(1, Ordering::Relaxed);
434    format!("dbcss-tooltip-{id}")
435}
436
437fn next_tooltip_trigger_id() -> String {
438    let id = NEXT_TOOLTIP_TRIGGER_ID.fetch_add(1, Ordering::Relaxed);
439    format!("dbcss-tooltip-trigger-{id}")
440}
441
442fn classes(base: &str, placement_class: &str, extra: &str) -> String {
443    if extra.is_empty() {
444        format!("{base} {placement_class}")
445    } else {
446        format!("{base} {placement_class} {extra}")
447    }
448}
449
450fn tooltip_style(position: Option<OverlayPosition>) -> String {
451    match position {
452        Some(position) => format!(
453            "position: fixed; left: {:.3}px; top: {:.3}px; z-index: 1080; pointer-events: none; white-space: nowrap; visibility: visible;",
454            position.x, position.y
455        ),
456        None => {
457            "position: fixed; left: 0; top: 0; z-index: 1080; pointer-events: none; white-space: nowrap; visibility: hidden;".to_string()
458        }
459    }
460}
461
462fn schedule_tooltip_visibility(
463    mut visible: Signal<bool>,
464    mut revision: Signal<u64>,
465    open: Option<bool>,
466    trigger: TooltipTriggers,
467    delay: TooltipDelay,
468    interactions: TooltipInteractionSignals,
469    mut positioning: TooltipPositioning,
470) {
471    let next_revision = *revision.read() + 1;
472    revision.set(next_revision);
473
474    let should_show = tooltip_should_show(open, trigger, interactions);
475    let delay_ms = if should_show {
476        delay.show_ms
477    } else {
478        delay.hide_ms
479    };
480
481    spawn(async move {
482        if delay_ms > 0 {
483            TimeoutFuture::new(delay_ms).await;
484        }
485
486        if *revision.read() != next_revision {
487            return;
488        }
489
490        let should_show = tooltip_should_show(open, trigger, interactions);
491        visible.set(should_show);
492
493        if should_show {
494            TimeoutFuture::new(0).await;
495            measure_tooltip_position(positioning);
496        } else {
497            positioning.overlay_position.set(None);
498        }
499    });
500}
501
502fn tooltip_should_show(
503    open: Option<bool>,
504    trigger: TooltipTriggers,
505    interactions: TooltipInteractionSignals,
506) -> bool {
507    if let Some(open) = open {
508        return open;
509    }
510
511    (trigger.hover && *interactions.hover_active.read())
512        || (trigger.focus && *interactions.focus_active.read())
513        || (trigger.click && *interactions.click_active.read())
514}
515
516fn measure_tooltip_position(mut positioning: TooltipPositioning) {
517    spawn(async move {
518        let Some(trigger_rect) = element_rect(&positioning.trigger_id).await else {
519            return;
520        };
521        let Some(tooltip_rect) = element_rect(&positioning.tooltip_id).await else {
522            return;
523        };
524        let Some(boundary) = viewport_boundary().await else {
525            return;
526        };
527
528        let fallback_placements = positioning
529            .fallback_placements
530            .into_iter()
531            .map(OverlayPlacement::from)
532            .collect::<Vec<_>>();
533
534        positioning
535            .overlay_position
536            .set(Some(calculate_overlay_position(
537                trigger_rect,
538                tooltip_rect,
539                boundary,
540                positioning.placement.into(),
541                &fallback_placements,
542                positioning.offset,
543                positioning.boundary_padding,
544            )));
545    });
546}
547
548async fn element_rect(id: &str) -> Option<OverlayRect> {
549    let id = format!("{id:?}");
550    let value = document::eval(&format!(
551        r#"
552        const element = document.getElementById({id});
553        if (!element) {{
554            return null;
555        }}
556        const rect = element.getBoundingClientRect();
557        return {{
558            x: rect.left,
559            y: rect.top,
560            width: rect.width,
561            height: rect.height
562        }};
563        "#
564    ))
565    .await
566    .ok()?;
567
568    if value.is_null() {
569        return None;
570    }
571
572    Some(OverlayRect::new(
573        value.get("x").and_then(|value| value.as_f64())?,
574        value.get("y").and_then(|value| value.as_f64())?,
575        value.get("width").and_then(|value| value.as_f64())?,
576        value.get("height").and_then(|value| value.as_f64())?,
577    ))
578}
579
580async fn viewport_boundary() -> Option<OverlayRect> {
581    let value = document::eval(
582        r#"
583        return {
584            x: 0,
585            y: 0,
586            width: window.innerWidth || document.documentElement.clientWidth || 0,
587            height: window.innerHeight || document.documentElement.clientHeight || 0
588        };
589        "#,
590    )
591    .await
592    .ok()?;
593
594    let rect = OverlayRect::new(
595        value.get("x").and_then(|value| value.as_f64())?,
596        value.get("y").and_then(|value| value.as_f64())?,
597        value.get("width").and_then(|value| value.as_f64())?,
598        value.get("height").and_then(|value| value.as_f64())?,
599    );
600
601    if rect.width <= 0.0 || rect.height <= 0.0 {
602        return None;
603    }
604
605    Some(rect)
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn trigger_defaults_to_hover_and_focus() {
614        let triggers = TooltipTriggers::default();
615        assert!(triggers.hover);
616        assert!(triggers.focus);
617        assert!(!triggers.click);
618    }
619
620    #[test]
621    fn placement_converts_to_overlay_placement() {
622        assert_eq!(
623            OverlayPlacement::from(TooltipPlacement::Auto),
624            OverlayPlacement::Auto
625        );
626        assert_eq!(
627            OverlayPlacement::from(TooltipPlacement::Bottom),
628            OverlayPlacement::Bottom
629        );
630    }
631
632    #[test]
633    fn placement_classes_match_bootstrap() {
634        assert_eq!(TooltipPlacement::Top.class(), "bs-tooltip-top");
635        assert_eq!(TooltipPlacement::Bottom.class(), "bs-tooltip-bottom");
636        assert_eq!(TooltipPlacement::Start.class(), "bs-tooltip-start");
637        assert_eq!(TooltipPlacement::End.class(), "bs-tooltip-end");
638    }
639}