yew_bootstrap/component/
tooltip.rs

1//! Implements tooltip suppport.
2//!
3//! `yew` presumes it has exclusive control of the DOM, which conflicts with the
4//! Bootstrap's assumption that it also has exclusive control of the DOM.
5//!
6//! So, we need to re-implement the Tooltip plugin using `yew`...
7//!
8//! * <https://github.com/react-bootstrap/react-bootstrap/blob/master/src/Tooltip.tsx>
9//! * <https://github.com/twbs/bootstrap/blob/main/js/src/tooltip.js>
10
11pub use popper_rs::prelude::Placement;
12use popper_rs::{
13    prelude::{use_popper, Modifier, Offset, Options, Strategy},
14    state::ApplyAttributes,
15};
16use wasm_bindgen::{closure::Closure, JsCast};
17use web_sys::{HtmlElement, MediaQueryList, MediaQueryListEvent};
18use yew::{html::IntoPropValue, platform::spawn_local, prelude::*};
19
20/// Media query to indicate that the primary pointing device is missing or does
21/// not support hovering.
22///
23/// Reference: [Media Queries Level 4: Hover Capability](https://www.w3.org/TR/mediaqueries-4/#hover)
24const MEDIA_QUERY_HOVER_NONE: &str = "(hover: none)";
25
26/// Media query to indicate that there is no pointing device which supports
27/// hovering.
28///
29/// Reference: [Media Queries Level 4: All Available Interaction Capabilities](https://www.w3.org/TR/mediaqueries-4/#any-input)
30const MEDIA_QUERY_ANY_HOVER_NONE: &str = "(any-hover: none)";
31
32/// Media query to indicate that there are either no pointing devices, or a
33/// pointing device only supports coarse input.
34///
35/// Reference: [Media Queries Level 4: All Available Interaction Capabilities](https://www.w3.org/TR/mediaqueries-4/#any-input)
36const MEDIA_QUERY_ANY_POINTER_NONE_OR_COARSE: &str = "(any-pointer: none) or (any-pointer: coarse)";
37
38/// Trigger options for [`TooltipProps::trigger_on_focus`].
39///
40/// This allows tooltips to be selectively enabled on focus, depending on the
41/// result of which [Interaction Media Features][0] the user's device supports.
42///
43/// [0]: https://www.w3.org/TR/mediaqueries-4/#mf-interaction
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub enum TooltipFocusTrigger {
46    /// Always show the tooltip on element focus.
47    ///
48    /// This is the default option, and provides a reliable and accessible
49    /// alternative when using a non-hover-capable device (such as a
50    /// touchscreen) or navigating with a keyboard on a device that *also* has a
51    /// pointing device.
52    ///
53    /// Because of the many side-effects, browser and platform bugs that come
54    /// from attempting to *selectively* disable showing tooltips on focus, this
55    /// is generally the best choice, but may lead to unexpected tooltip display
56    /// for users on a desktop browser with a traditional mouse.
57    #[default]
58    Always,
59
60    /// Show the tooltip on element focus only if the *primary* pointing device
61    /// does *not* support hovering (eg: touchscreen), or there are no pointing
62    /// devices connected (`hover: none`).
63    ///
64    /// If the primary pointing device supports hovering (eg: mouse, trackpad,
65    /// trackball, smart pen, Wiimote, Leap Motion Controller), the tooltip will
66    /// not be shown when the element has focus.
67    ///
68    /// Figuring out what the "primary" pointing device actually is
69    /// [can be complicated to answer for some devices][0]. They generally err
70    /// towards reporting the use and presence of an ordinary mouse with hover
71    /// capabilities (eg: [Firefox bug 1851244][1]), even when there are no
72    /// pointing devices connected, or used with a touchscreen.
73    ///
74    /// Both [Chromium][2] and [Firefox][3] on Windows erroneously report
75    /// touch-only devices as having an ordinary mouse with hover capabilities
76    /// if the device lacks an auto-rotation sensor (even if disabled), which is
77    /// generally the case for non-tablet devices like external touchscreen
78    /// monitors and all-in-one PCs).
79    ///
80    /// [Some Android devices also erroneously report `hover: hover`][4], even
81    /// when they only have a touch screen.
82    ///
83    /// Safari on iOS *always* reports `hover: none`, even when using an iPad
84    /// with hover-capable pointing devices, such as the Apple Pencil (stylus),
85    /// Magic Keyboard (trackpad) or an ordinary mouse with an external display.
86    /// All hover-capable devices *except* the Pencil are reported via
87    /// `any-hover: hover`.
88    ///
89    /// For someone who primarily uses a keyboard to interact with their
90    /// computer, but has a mouse plugged in (eg: a laptop with a built-in
91    /// trackpad, or a virtual device), their browser will still report a
92    /// primary pointing device which is "hover capable", even when they have no
93    /// way to hover.
94    ///
95    /// These implementation problems and shortfalls make the `hover: none`
96    /// media query unreliable, and an unreliable indicator of user preferences.
97    ///
98    /// [0]: https://firefox-source-docs.mozilla.org/widget/windows/windows-pointing-device/index.html#selection-of-the-primary-pointing-device
99    /// [1]: https://bugzilla.mozilla.org/show_bug.cgi?id=1851244
100    /// [2]: https://issues.chromium.org/issues/366055333
101    /// [3]: https://bugzilla.mozilla.org/show_bug.cgi?id=1918292
102    /// [4]: https://issues.chromium.org/issues/41445959
103    IfHoverNone,
104
105    /// Trigger showing the tooltip on element focus only if *all* pointing
106    /// devices connected to the device do not support hovering, or there there
107    /// are no pointing devices connected (`any-hover: none`).
108    ///
109    /// For a device with *only* one non-hovering pointing device (eg: a mobile
110    /// phone with a touch screen or basic stylus), this is the same as
111    /// [`TooltipFocusTrigger::IfHoverNone`].
112    ///
113    /// For a device with *both* hovering and non-hovering pointing device(s)
114    /// (eg: a laptop with a trackpad and touchscreen, or a tablet with both pen
115    /// and touch input), this option will never show will never show the
116    /// tooltip on focus.
117    ///
118    /// Unfortunately, [there is no way to detect if not *all* pointer devices support hovering][1].
119    ///
120    /// Most desktop browsers will *always* report the presence of an ordinary
121    /// (hover-capable) mouse, even if none is attached. This can be caused by:
122    ///
123    /// * a wireless mouse dongle which is plugged in, but the wireless mouse
124    ///   itself is turned off
125    ///
126    /// * the presence of a PS/2 mouse controller
127    ///
128    /// * the presence of a virtual mouse device
129    ///
130    /// * a touch screen which does not have an automatic rotation sensor
131    ///   (but this will report hover events from touch), due to [Chromium][2]
132    ///   and [Firefox][3] bugs.
133    ///
134    /// These issues may also impact someone who primarily uses a keyboard to
135    /// interact with their computer.
136    ///
137    /// These implementation problems and shortfalls make the `any-hover: none`
138    /// media query an unreliable indicator of user preferences.
139    ///
140    /// [1]: https://github.com/w3c/csswg-drafts/issues/5462
141    /// [2]: https://issues.chromium.org/issues/366055333
142    /// [3]: https://bugzilla.mozilla.org/show_bug.cgi?id=1918292
143    IfAnyHoverNone,
144
145    /// Trigger showing tooltips on element focus only if:
146    ///
147    /// * there are no pointer devices present (`any-pointer: none`), **or**,
148    /// * there are *coarse* pointer devices present (`any-pointer: coarse`),
149    ///   such as a touchscreen, Wiimote or Leap Motion Controller
150    ///
151    /// This is a work-around for there being
152    /// [no way for a browser to report that not all devices support `hover`][1],
153    /// and the complex heuristics required (which all browsers lack) to
154    /// determine which is the "primary" pointing device on desktop and laptop
155    /// computers.
156    ///
157    /// The intent of this mode is that tooltips will be shown on `focus` for
158    /// devices with touchscreens, *regardless* of whether they have an
159    /// auto-rotation sensor.
160    ///
161    /// The side-effects are:
162    ///
163    /// * hovering `coarse` pointer devices (like the Wiimote and Leap Motion
164    ///   Controller) will *also* show tooltips on focus, even though they can
165    ///   hover
166    /// * traditional-style laptops with touchscreens (ie: not foldable or
167    ///   convertible into a tablet) will *also* show tooltips on focus, even
168    ///   though using the touchscreen as a primary pointing device is very
169    ///   uncomfortable (because it requires reaching over the keyboard)
170    /// * non-hovering `fine` pointer devices (like basic stylus digitisers)
171    ///   will *not* show tooltips on focus, even though they can't hover
172    /// * a user primarily using non-pointer (keyboard) input but with at least
173    ///   one `fine` pointing device connected (such as a laptop with built-in
174    ///   trackpad) will never see tooltips on focus
175    /// * [Safari doesn't fire `focus` events][2] on components on click or
176    ///   touch if it does not accept keyboard input (eg: `<a>` and `<button>`)
177    ///
178    /// [1]: https://github.com/w3c/csswg-drafts/issues/5462
179    /// [2]: https://webkit.org/b/22261#c68
180    IfAnyPointerNoneOrCoarse,
181
182    /// Never show the tooltip on element focus.
183    ///
184    /// Make sure there is some other way to trigger the tooltip which works on
185    /// all types of devices and meets users' preferred input modalities.
186    Never,
187}
188
189impl IntoPropValue<TooltipFocusTrigger> for bool {
190    fn into_prop_value(self) -> TooltipFocusTrigger {
191        if self {
192            TooltipFocusTrigger::Always
193        } else {
194            TooltipFocusTrigger::Never
195        }
196    }
197}
198
199impl TooltipFocusTrigger {
200    fn media_queries(&self) -> Option<MediaQueryList> {
201        let query = match self {
202            Self::Always | Self::Never => return None,
203            Self::IfHoverNone => MEDIA_QUERY_HOVER_NONE,
204            Self::IfAnyHoverNone => MEDIA_QUERY_ANY_HOVER_NONE,
205            Self::IfAnyPointerNoneOrCoarse => MEDIA_QUERY_ANY_POINTER_NONE_OR_COARSE,
206        };
207        let w = gloo_utils::window();
208        w.match_media(query).ok().flatten()
209    }
210
211    fn should_trigger(&self) -> bool {
212        let Some(queries) = self.media_queries() else {
213            return match self {
214                Self::Always => true,
215                Self::Never => false,
216                _ => unreachable!(),
217            };
218        };
219
220        queries.matches()
221    }
222}
223
224#[derive(Properties, Clone, PartialEq)]
225pub struct TooltipProps {
226    /// The node which this tooltip is attached to.
227    ///
228    /// If the `target` can be `disabled`, pass the same value to
229    /// [Tooltip's `disabled` property][Self::disabled] to ensure that the
230    /// tooltip will be automatically hidden, even if it had focus, was being
231    /// hovered or clicked.
232    pub target: NodeRef,
233
234    /// ID of the tooltip.
235    ///
236    /// If this is set, [Tooltip] will set the `target`'s `aria-describedby`
237    /// attribute whenever it is visible.
238    #[prop_or_default]
239    pub id: Option<AttrValue>,
240
241    /// Content of the tooltip.
242    #[prop_or_default]
243    pub children: Children,
244
245    /// Placement of the tooltip.
246    ///
247    /// [Popper's website shows all placement options][0].
248    ///
249    /// [0]: https://popper.js.org/
250    #[prop_or_default]
251    pub placement: Placement,
252
253    /// Use fade transition when showing or hiding the tooltip.
254    #[prop_or_default]
255    pub fade: bool,
256
257    /// If `true`, always show the tooltip, regardless of focus state.
258    ///
259    /// [`disabled = true`][TooltipProps::disabled] overrides this option.
260    #[prop_or_default]
261    pub show: bool,
262
263    /// Show the tooltip when the [`target`][Self::target] node recieves input
264    /// or keyboard focus.
265    ///
266    /// This defaults to [`TooltipFocusTrigger::Always`], which always shows the
267    /// tooltip on input focus. See [`TooltipFocusTrigger`] for other options
268    /// which selectively disable this behaviour based on media queries,
269    /// and full caveats on each.
270    ///
271    /// This [will not trigger on `disabled` elements][0].
272    ///
273    /// ## Safari/WebKit focus events
274    ///
275    /// *Unlike* most other web browsers (even on macOS), Safari and other
276    /// browsers using the WebKit renderer[^1] do not fire `focus` events for
277    /// components which *do not* accept keyboard input (such as `<a>` and
278    /// `<button>` elements) when clicked or touched,
279    /// [following macOS conventions][2].
280    ///
281    /// In Safari on macOS, pressing <kbd>Option</kbd> + <kbd>Tab</kbd> will
282    /// allow you to focus components that does not accept keyboard input, and
283    /// will fire a `focus` event similarly to other platforms. If
284    /// [keyboard navigation is enabled in System Settings][3], pressing
285    /// <kbd>Tab</kbd> cycles focus between any component (like other
286    /// platforms).
287    ///
288    /// Safari will fire `focus` **and** (potentially-synthetic) `hover` events
289    /// for components which accept keyboard input (such as
290    /// `<input type="text">`) when clicked *or touched*. Touchscreen devices on
291    /// most other platforms will only fire `focus` events on touch, not
292    /// `hover`.
293    ///
294    /// [^1]: [Outside of the European Union][4], **all** browsers on iOS *also* use WebKit.
295    ///
296    /// [0]: https://getbootstrap.com/docs/5.3/components/tooltips/#disabled-elements
297    /// [2]: https://webkit.org/b/22261#c68
298    /// [3]: https://support.apple.com/en-au/guide/mac-help/mchlp1399/14.0/mac/14.0
299    /// [4]: https://developer.apple.com/support/alternative-browser-engines/
300    #[prop_or_default]
301    pub trigger_on_focus: TooltipFocusTrigger,
302
303    /// Show the tooltip when the [`target`][Self::target] component has the
304    /// mouse cursor hovered over it.
305    ///
306    /// This defaults to `true`, but [will not trigger on `disabled` elements][0].
307    ///
308    /// **Note:** touchscreen devices and keyboard-only users *may not* trigger
309    /// hover events. Ensure there is some other way to trigger the tooltip on
310    /// those devices, such as with
311    /// [`trigger_on_focus={TooltipFocusTrigger::Always}`][1].
312    ///
313    /// Safari on iOS reports synthetic `mouseenter` events on touchscreen
314    /// devices, when browsers on other platforms with touchscreens
315    /// traditionally do not.
316    ///
317    /// However, [Safari on iOS does not fire `focus` events][2] for components
318    /// which do not accept keyboard input (such as `<a>` and `<button>`), so
319    /// synthetic `hover` events are the only way to trigger tooltips.
320    ///
321    /// [0]: https://getbootstrap.com/docs/5.3/components/tooltips/#disabled-elements
322    /// [1]: Self::trigger_on_focus
323    /// [2]: https://webkit.org/b/22261#c68
324    #[prop_or(true)]
325    pub trigger_on_hover: bool,
326
327    /// If `true`, always hide the tooltip. *This overrides all other
328    /// conditions.*
329    ///
330    /// The tooltip will remain part of the DOM.
331    ///
332    /// [Disabled elements don't fire events][0], including `focusout` on a
333    /// currently-focused element and `mouseleave` of a currently-hovered
334    /// element. This could cause a tooltip to be "stuck" being shown.
335    ///
336    /// This property allows you to automatically hide a [Tooltip] which has
337    /// [`trigger_on_focus = true`][Self::trigger_on_focus] or
338    /// [`trigger_on_hover = true`][Self::trigger_on_hover] whenever the
339    /// [`target`][Self::target] is disabled.
340    ///
341    /// **Warning:** entirely removing the [Tooltip] from the DOM whenever the
342    /// [`target`][Self::target] is disabled can cause strange behaviour if the
343    /// that tooltip is currently being displayed.
344    ///
345    /// [0]: https://getbootstrap.com/docs/5.3/components/tooltips/#disabled-elements
346    #[prop_or_default]
347    pub disabled: bool,
348}
349
350/// # Tooltip component
351///
352/// Tooltip which is automatically shown when an element is focused or hovered.
353///
354/// [Bootstrap's tooltips][0] depend on Popper, which assumes complete control
355/// of the DOM. Yew *also* assumes complete control of the DOM, so this can lead
356/// to unexpected behaviour whenever it reuses DOM components – so you can't
357/// just use `data-bs-toggle="tooltip"`.
358///
359/// This component is similar to [`react-bootstrap`'s Tooltip component][2] –
360/// it wires up Popper in a way that works nicely with Yew.
361///
362/// There are some similarities and differences between this component and
363/// Bootstrap's in-built implementation:
364///
365/// * There's no need to use `bootstrap.bundle.min.js` or `popper.min.js`. This
366///   component uses [`popper-rs`][], which comes with Popper.
367///
368/// * You can't trigger or describe tooltips with `data-bs-*` attributes.
369///
370/// * The `<Tooltip>`'s content is set with the [`children`][] property, which
371///   supports arbitrary HTML.
372///
373///   It doesn't support the `sanitize`, `sanitizeFn` or `title` attributes.
374///
375/// * Like Bootstrap, `<Tooltip>` is triggered by
376///   [input focus][trigger_on_focus] and [mouse hover][trigger_on_hover] by
377///   default.
378///
379///   These triggers can be individually disabled, and you can
380///   [control display manually][`show`] instead.
381///
382///   `<Tooltip>` *does not* support the `click` trigger – use input focus
383///   instead. This makes it possible to trigger tooltips when there is *no*
384///   pointing device available or it cannot be used.
385///
386/// * Like Bootstrap, tooltips exist in a shadow DOM ([portal][]) outside of the
387///   normal page hierarchy.
388///
389///   Unlike Bootstrap, the `<Tooltip>` is *always* present in the DOM, even
390///   when the tooltip is not displayed.
391///
392///   A `<Tooltip>` needs to remain part of the DOM if it *could* be shown in a
393///   component. Use the [`show`][] and [`disabled`][] properties to
394///   control its display.
395///
396/// * When using a [`target`][] which could be `disabled` and triggering
397///   on focus and/or on hover, you can prevent the tooltip from being displayed
398///   by setting the [`disabled`][] property on the on the `<Tooltip>`
399///   as well. Otherwise, the `target` won't fire an event to *hide* the tooltip
400///   when it loses focus or isn't hovered.
401///
402///   If you only ever trigger tooltips manually, then there's no need to sync
403///   the `disabled` state.
404///
405/// * Like Bootstrap, if you want the tooltip to be displayed on focus or on
406///   hover on a `disabled` [`target`][], you'll need to use a
407///   [wrapper element][].
408///
409/// ## Examples
410///
411/// Button with a tooltip, shown automatically on focus or hover:
412///
413/// ```rust
414/// use yew::prelude::*;
415/// use yew_bootstrap::component::{Button, Tooltip};
416/// use yew_bootstrap::util::Color;
417///
418/// fn test() -> Html {
419///     let btn_ref = NodeRef::default();
420///     html! {
421///         <>
422///             <Button style={Color::Primary} node_ref={btn_ref.clone()}>
423///                 {"Button with tooltip"}
424///             </Button>
425///             <Tooltip target={btn_ref}>
426///                 {"Tooltip for button."}
427///             </Tooltip>
428///         </>
429///     }
430/// }
431/// ```
432///
433/// [0]: https://getbootstrap.com/docs/5.3/components/tooltips/
434/// [2]: https://github.com/react-bootstrap/react-bootstrap/blob/master/src/Tooltip.tsx
435/// [`children`]: TooltipProps::children
436/// [`disabled`]: TooltipProps::disabled
437/// [portal]: https://yew.rs/docs/advanced-topics/portals
438/// [`popper-rs`]: https://github.com/ctron/popper-rs/
439/// [`show`]: TooltipProps::show
440/// [`target`]: TooltipProps::target
441/// [trigger_on_focus]: TooltipProps::trigger_on_focus
442/// [trigger_on_hover]: TooltipProps::trigger_on_hover
443/// [wrapper element]: https://getbootstrap.com/docs/5.3/components/tooltips/#disabled-elements
444#[function_component]
445pub fn Tooltip(props: &TooltipProps) -> Html {
446    let tooltip_ref = use_node_ref();
447
448    // Adapted from https://github.com/ctron/popper-rs/blob/main/examples/yew/src/example/basic.rs
449    let options = use_memo(props.placement, |placement| Options {
450        placement: *placement,
451        modifiers: vec![Modifier::Offset(Offset {
452            skidding: 0,
453            distance: 6,
454        })],
455        strategy: Strategy::Fixed,
456        ..Default::default()
457    });
458
459    let popper = use_popper(props.target.clone(), tooltip_ref.clone(), options).unwrap();
460
461    let focused = use_state_eq(|| false);
462    let focus_should_trigger = use_state_eq(|| props.trigger_on_focus.should_trigger());
463    let hovered = use_state_eq(|| false);
464
465    let onshow = {
466        let focused = focused.clone();
467        let hovered = hovered.clone();
468        Callback::from(move |evt_type: String| match evt_type.as_str() {
469            "mouseenter" => hovered.set(true),
470            "focusin" => focused.set(true),
471            _ => {}
472        })
473    };
474
475    let onhide = {
476        let focused = focused.clone();
477        let hovered = hovered.clone();
478        Callback::from(move |evt_type: String| match evt_type.as_str() {
479            "mouseleave" => hovered.set(false),
480            "focusout" => focused.set(false),
481            _ => {}
482        })
483    };
484
485    let focus_should_trigger_listener = {
486        let focus_should_trigger = focus_should_trigger.clone();
487
488        Callback::from(move |v: bool| {
489            focus_should_trigger.set(v);
490        })
491    };
492
493    use_effect_with(props.trigger_on_focus, |trigger_on_focus| {
494        let r = if let Some(media_query_list) = trigger_on_focus.media_queries() {
495            let media_query_list_listener = Closure::<dyn Fn(MediaQueryListEvent)>::wrap(Box::new(
496                move |e: MediaQueryListEvent| {
497                    focus_should_trigger_listener.emit(e.matches());
498                },
499            ));
500
501            let _ = media_query_list.add_event_listener_with_callback(
502                "change",
503                media_query_list_listener.as_ref().unchecked_ref(),
504            );
505
506            Some((media_query_list_listener, media_query_list))
507        } else {
508            // Current trigger_on_focus rule doesn't need a MediaQueryList change event listener.
509            None
510        };
511
512        move || {
513            if let Some((media_query_list_listener, media_query_list)) = r {
514                let _ = media_query_list.remove_event_listener_with_callback(
515                    "change",
516                    media_query_list_listener.as_ref().unchecked_ref(),
517                );
518
519                drop(media_query_list_listener);
520            }
521        }
522    });
523
524    if props.disabled {
525        // Whenever this component is disabled, explicitly set our focus and
526        // hover state to false.
527        focused.set(false);
528        hovered.set(false);
529    }
530
531    let show = !props.disabled
532        && (props.show
533            || (*focused && *focus_should_trigger)
534            || (*hovered && props.trigger_on_hover));
535    let data_show = show.then(AttrValue::default);
536
537    use_effect_with((show, popper.instance.clone()), |(show, popper)| {
538        if *show {
539            let popper = popper.clone();
540
541            spawn_local(async move {
542                popper.update().await;
543            });
544        }
545    });
546
547    use_effect_with(
548        (tooltip_ref.clone(), popper.state.attributes.popper.clone()),
549        |(tooltip_ref, attributes)| {
550            tooltip_ref.apply_attributes(attributes);
551        },
552    );
553
554    // Attach event handlers. These are always wired up, just we ignore the
555    // result when they're disabled.
556    use_effect_with(props.target.clone(), |target_ref| {
557        let show_listener = Closure::<dyn Fn(Event)>::wrap(Box::new(move |e: Event| {
558            onshow.emit(e.type_());
559        }));
560        let hide_listener = Closure::<dyn Fn(Event)>::wrap(Box::new(move |e: Event| {
561            onhide.emit(e.type_());
562        }));
563        let target_elem = target_ref.cast::<HtmlElement>();
564
565        if let Some(target_elem) = &target_elem {
566            let _ = target_elem.add_event_listener_with_callback(
567                "focusin",
568                show_listener.as_ref().unchecked_ref(),
569            );
570            let _ = target_elem.add_event_listener_with_callback(
571                "focusout",
572                hide_listener.as_ref().unchecked_ref(),
573            );
574
575            let _ = target_elem.add_event_listener_with_callback(
576                "mouseenter",
577                show_listener.as_ref().unchecked_ref(),
578            );
579            let _ = target_elem.add_event_listener_with_callback(
580                "mouseleave",
581                hide_listener.as_ref().unchecked_ref(),
582            );
583        };
584
585        move || {
586            if let Some(target_elem) = target_elem {
587                let _ = target_elem.remove_event_listener_with_callback(
588                    "focusin",
589                    show_listener.as_ref().unchecked_ref(),
590                );
591                let _ = target_elem.remove_event_listener_with_callback(
592                    "focusout",
593                    hide_listener.as_ref().unchecked_ref(),
594                );
595                let _ = target_elem.remove_event_listener_with_callback(
596                    "mouseenter",
597                    show_listener.as_ref().unchecked_ref(),
598                );
599                let _ = target_elem.remove_event_listener_with_callback(
600                    "mouseleave",
601                    hide_listener.as_ref().unchecked_ref(),
602                );
603            }
604            drop(show_listener);
605            drop(hide_listener);
606        }
607    });
608
609    use_effect_with(
610        (props.target.clone(), props.id.clone(), show),
611        |(target_ref, tooltip_id, show)| {
612            let Some(target_elem) = target_ref.cast::<HtmlElement>() else {
613                return;
614            };
615
616            match (tooltip_id, show) {
617                (Some(tooltip_id), true) => {
618                    let _ = target_elem.set_attribute("aria-describedby", tooltip_id);
619                }
620                _ => {
621                    let _ = target_elem.remove_attribute("aria-describedby");
622                }
623            }
624        },
625    );
626
627    let mut class = classes!["tooltip", "bs-tooltip-auto"];
628    if props.fade {
629        class.push("fade");
630    }
631    if show {
632        class.push("show");
633    }
634
635    let mut popper_style = popper.state.styles.popper.clone();
636    // Make sure `<Tooltip>` doesn't interfere with events going to other
637    // elements, even when hidden.
638    popper_style.insert("pointer-events".to_string(), "none".to_string());
639
640    create_portal(
641        html_nested! {
642            <div
643                ref={&tooltip_ref}
644                role="tooltip"
645                {class}
646                style={&popper_style}
647                data-show={&data_show}
648                id={props.id.clone()}
649            >
650                <div
651                    class="tooltip-arrow"
652                    data-popper-arrow="true"
653                    style={&popper.state.styles.arrow}
654                />
655                <div class="tooltip-inner">
656                    { for props.children.iter() }
657                </div>
658            </div>
659        },
660        gloo_utils::body().into(),
661    )
662}