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