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-flex;".to_string()
180    } else {
181        format!("display: inline-flex; {}", 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 arrow_style = arrow_style(*overlay_position.read(), effective_placement);
277    let describedby = if is_visible {
278        tooltip_id.read().clone()
279    } else {
280        String::new()
281    };
282
283    let effect_has_text = has_text;
284    let positioning = TooltipPositioning {
285        trigger_id: trigger_id.read().clone(),
286        tooltip_id: tooltip_id.read().clone(),
287        overlay_position,
288        placement: props.placement,
289        fallback_placements: props.fallback_placements.clone(),
290        offset: props.offset,
291        boundary_padding: props.boundary_padding,
292    };
293    let effect_trigger_id = trigger_id.read().clone();
294    let effect_tooltip_id = tooltip_id.read().clone();
295    let mut effect_overlay_position = overlay_position;
296    use_effect(use_reactive(
297        (
298            &props.open,
299            &effect_has_text,
300            &props.placement,
301            &props.fallback_placements,
302            &props.offset,
303            &props.boundary_padding,
304        ),
305        move |(open, has_text, placement, fallback_placements, offset, boundary_padding)| {
306            let current_visible = has_text && open.unwrap_or(*visible.read());
307
308            if current_visible {
309                measure_tooltip_position(TooltipPositioning {
310                    trigger_id: effect_trigger_id.clone(),
311                    tooltip_id: effect_tooltip_id.clone(),
312                    overlay_position: effect_overlay_position,
313                    placement,
314                    fallback_placements,
315                    offset,
316                    boundary_padding,
317                });
318            } else {
319                effect_overlay_position.set(None);
320            }
321        },
322    ));
323
324    let hover_enter_positioning = positioning.clone();
325    let hover_leave_positioning = positioning.clone();
326    let focus_in_positioning = positioning.clone();
327    let focus_out_positioning = positioning.clone();
328    let click_positioning = positioning.clone();
329    let interactions = TooltipInteractionSignals {
330        hover_active,
331        focus_active,
332        click_active,
333    };
334
335    rsx! {
336        span {
337            id: "{trigger_id}",
338            class: "tooltip-wrapper",
339            // `inline-flex` hugs the trigger; `inline-block` would add line-box
340            // leading and push the measured anchor box below the element, landing
341            // the tooltip low (see the same fix on `.popover-wrapper`).
342            style: "display: inline-flex;",
343            "aria-describedby": "{describedby}",
344            onmouseenter: move |_| {
345                if props.trigger.hover && props.open.is_none() {
346                    hover_active.set(true);
347                    schedule_tooltip_visibility(
348                        visible,
349                        visibility_revision,
350                        props.open,
351                        props.trigger,
352                        props.delay,
353                        interactions,
354                        hover_enter_positioning.clone(),
355                    );
356                }
357            },
358            onmouseleave: move |_| {
359                if props.trigger.hover && props.open.is_none() {
360                    hover_active.set(false);
361                    schedule_tooltip_visibility(
362                        visible,
363                        visibility_revision,
364                        props.open,
365                        props.trigger,
366                        props.delay,
367                        interactions,
368                        hover_leave_positioning.clone(),
369                    );
370                }
371            },
372            onfocusin: move |_| {
373                if props.trigger.focus && props.open.is_none() {
374                    focus_active.set(true);
375                    schedule_tooltip_visibility(
376                        visible,
377                        visibility_revision,
378                        props.open,
379                        props.trigger,
380                        props.delay,
381                        interactions,
382                        focus_in_positioning.clone(),
383                    );
384                }
385            },
386            onfocusout: move |_| {
387                if props.trigger.focus && props.open.is_none() {
388                    focus_active.set(false);
389                    schedule_tooltip_visibility(
390                        visible,
391                        visibility_revision,
392                        props.open,
393                        props.trigger,
394                        props.delay,
395                        interactions,
396                        focus_out_positioning.clone(),
397                    );
398                }
399            },
400            onclick: move |_| {
401                if props.trigger.click && props.open.is_none() {
402                    let next_click_active = {
403                        let active = click_active.read();
404                        !*active
405                    };
406                    click_active.set(next_click_active);
407                    schedule_tooltip_visibility(
408                        visible,
409                        visibility_revision,
410                        props.open,
411                        props.trigger,
412                        props.delay,
413                        interactions,
414                        click_positioning.clone(),
415                    );
416                }
417            },
418
419            {props.children}
420
421            if is_visible {
422                div {
423                    id: "{tooltip_id}",
424                    class: "{tooltip_class}",
425                    role: "tooltip",
426                    "data-popper-placement": "{placement_value}",
427                    style: "{tooltip_style}",
428                    div { class: "tooltip-arrow", style: "{arrow_style}" }
429                    div { class: "tooltip-inner", "{props.text}" }
430                }
431            }
432        }
433    }
434}
435
436fn next_tooltip_id() -> String {
437    let id = NEXT_TOOLTIP_ID.fetch_add(1, Ordering::Relaxed);
438    format!("dbcss-tooltip-{id}")
439}
440
441fn next_tooltip_trigger_id() -> String {
442    let id = NEXT_TOOLTIP_TRIGGER_ID.fetch_add(1, Ordering::Relaxed);
443    format!("dbcss-tooltip-trigger-{id}")
444}
445
446fn classes(base: &str, placement_class: &str, extra: &str) -> String {
447    if extra.is_empty() {
448        format!("{base} {placement_class}")
449    } else {
450        format!("{base} {placement_class} {extra}")
451    }
452}
453
454fn tooltip_style(position: Option<OverlayPosition>) -> String {
455    match position {
456        Some(position) => format!(
457            "position: fixed; left: {:.3}px; top: {:.3}px; z-index: 1080; pointer-events: none; white-space: nowrap; visibility: visible;",
458            position.x, position.y
459        ),
460        None => {
461            "position: fixed; left: 0; top: 0; z-index: 1080; pointer-events: none; white-space: nowrap; visibility: hidden;".to_string()
462        }
463    }
464}
465
466/// Bootstrap's tooltip arrow is `0.8rem` wide (`--bs-tooltip-arrow-width`); half of
467/// that centres the arrow element on the computed cross-axis point.
468const TOOLTIP_ARROW_HALF: f64 = 6.4;
469
470/// Inline style that slides the `.tooltip-arrow` along the tooltip's cross axis so
471/// it keeps pointing at the trigger after the box is clamped to the viewport — the
472/// job Popper.js does for a real Bootstrap tooltip. `position: absolute` is
473/// required: Bootstrap positions the arrow along the main edge only once Popper has
474/// made the element absolutely positioned, so without it the offset is a no-op.
475fn arrow_style(position: Option<OverlayPosition>, placement: TooltipPlacement) -> String {
476    let Some(position) = position else {
477        return String::new();
478    };
479    let edge = position.arrow - TOOLTIP_ARROW_HALF;
480    match placement {
481        TooltipPlacement::Start | TooltipPlacement::End => {
482            format!("position: absolute; top: {edge:.3}px;")
483        }
484        _ => format!("position: absolute; left: {edge:.3}px;"),
485    }
486}
487
488fn schedule_tooltip_visibility(
489    mut visible: Signal<bool>,
490    mut revision: Signal<u64>,
491    open: Option<bool>,
492    trigger: TooltipTriggers,
493    delay: TooltipDelay,
494    interactions: TooltipInteractionSignals,
495    mut positioning: TooltipPositioning,
496) {
497    let next_revision = *revision.read() + 1;
498    revision.set(next_revision);
499
500    let should_show = tooltip_should_show(open, trigger, interactions);
501    let delay_ms = if should_show {
502        delay.show_ms
503    } else {
504        delay.hide_ms
505    };
506
507    spawn(async move {
508        if delay_ms > 0 {
509            TimeoutFuture::new(delay_ms).await;
510        }
511
512        if *revision.read() != next_revision {
513            return;
514        }
515
516        let should_show = tooltip_should_show(open, trigger, interactions);
517        visible.set(should_show);
518
519        if should_show {
520            TimeoutFuture::new(0).await;
521            measure_tooltip_position(positioning);
522        } else {
523            positioning.overlay_position.set(None);
524        }
525    });
526}
527
528fn tooltip_should_show(
529    open: Option<bool>,
530    trigger: TooltipTriggers,
531    interactions: TooltipInteractionSignals,
532) -> bool {
533    if let Some(open) = open {
534        return open;
535    }
536
537    (trigger.hover && *interactions.hover_active.read())
538        || (trigger.focus && *interactions.focus_active.read())
539        || (trigger.click && *interactions.click_active.read())
540}
541
542fn measure_tooltip_position(mut positioning: TooltipPositioning) {
543    spawn(async move {
544        let Some(trigger_rect) = element_rect(&positioning.trigger_id).await else {
545            return;
546        };
547        let Some(tooltip_rect) = element_rect(&positioning.tooltip_id).await else {
548            return;
549        };
550        let Some(boundary) = viewport_boundary().await else {
551            return;
552        };
553
554        let fallback_placements = positioning
555            .fallback_placements
556            .into_iter()
557            .map(OverlayPlacement::from)
558            .collect::<Vec<_>>();
559
560        positioning
561            .overlay_position
562            .set(Some(calculate_overlay_position(
563                trigger_rect,
564                tooltip_rect,
565                boundary,
566                positioning.placement.into(),
567                &fallback_placements,
568                positioning.offset,
569                positioning.boundary_padding,
570            )));
571    });
572}
573
574async fn element_rect(id: &str) -> Option<OverlayRect> {
575    let id = format!("{id:?}");
576    let value = document::eval(&format!(
577        r#"
578        const element = document.getElementById({id});
579        if (!element) {{
580            return null;
581        }}
582        const rect = element.getBoundingClientRect();
583        return {{
584            x: rect.left,
585            y: rect.top,
586            width: rect.width,
587            height: rect.height
588        }};
589        "#
590    ))
591    .await
592    .ok()?;
593
594    if value.is_null() {
595        return None;
596    }
597
598    Some(OverlayRect::new(
599        value.get("x").and_then(|value| value.as_f64())?,
600        value.get("y").and_then(|value| value.as_f64())?,
601        value.get("width").and_then(|value| value.as_f64())?,
602        value.get("height").and_then(|value| value.as_f64())?,
603    ))
604}
605
606async fn viewport_boundary() -> Option<OverlayRect> {
607    let value = document::eval(
608        r#"
609        return {
610            x: 0,
611            y: 0,
612            width: window.innerWidth || document.documentElement.clientWidth || 0,
613            height: window.innerHeight || document.documentElement.clientHeight || 0
614        };
615        "#,
616    )
617    .await
618    .ok()?;
619
620    let rect = OverlayRect::new(
621        value.get("x").and_then(|value| value.as_f64())?,
622        value.get("y").and_then(|value| value.as_f64())?,
623        value.get("width").and_then(|value| value.as_f64())?,
624        value.get("height").and_then(|value| value.as_f64())?,
625    );
626
627    if rect.width <= 0.0 || rect.height <= 0.0 {
628        return None;
629    }
630
631    Some(rect)
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn trigger_defaults_to_hover_and_focus() {
640        let triggers = TooltipTriggers::default();
641        assert!(triggers.hover);
642        assert!(triggers.focus);
643        assert!(!triggers.click);
644    }
645
646    #[test]
647    fn placement_converts_to_overlay_placement() {
648        assert_eq!(
649            OverlayPlacement::from(TooltipPlacement::Auto),
650            OverlayPlacement::Auto
651        );
652        assert_eq!(
653            OverlayPlacement::from(TooltipPlacement::Bottom),
654            OverlayPlacement::Bottom
655        );
656    }
657
658    #[test]
659    fn placement_classes_match_bootstrap() {
660        assert_eq!(TooltipPlacement::Top.class(), "bs-tooltip-top");
661        assert_eq!(TooltipPlacement::Bottom.class(), "bs-tooltip-bottom");
662        assert_eq!(TooltipPlacement::Start.class(), "bs-tooltip-start");
663        assert_eq!(TooltipPlacement::End.class(), "bs-tooltip-end");
664    }
665
666    #[test]
667    fn arrow_style_offsets_on_cross_axis() {
668        let pos = OverlayPosition {
669            x: 100.0,
670            y: 50.0,
671            placement: OverlayPlacement::Bottom,
672            fits: false,
673            arrow: 30.0,
674        };
675        // Top/Bottom placements slide the arrow in x; the arrow element is centred
676        // on `position.arrow`, so its leading edge is that minus the arrow half-width.
677        let bottom = arrow_style(Some(pos), TooltipPlacement::Bottom);
678        assert!(bottom.contains("position: absolute"));
679        assert!(bottom.contains("left: 23.600px"));
680        // Start/End placements slide it in y instead.
681        let end = arrow_style(Some(pos), TooltipPlacement::End);
682        assert!(end.contains("top: 23.600px"));
683        // No measured position yet -> no offset emitted.
684        assert_eq!(arrow_style(None, TooltipPlacement::Bottom), "");
685    }
686}