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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16pub enum TooltipPlacement {
17 Auto,
19 #[default]
21 Top,
22 Bottom,
24 Start,
26 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#[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 pub const HOVER_FOCUS: Self = Self {
89 hover: true,
90 focus: true,
91 click: false,
92 };
93
94 pub const HOVER: Self = Self {
96 hover: true,
97 focus: false,
98 click: false,
99 };
100
101 pub const FOCUS: Self = Self {
103 hover: false,
104 focus: true,
105 click: false,
106 };
107
108 pub const CLICK: Self = Self {
110 hover: false,
111 focus: false,
112 click: true,
113 };
114
115 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#[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#[derive(Clone, PartialEq, Props)]
166pub struct TooltipDisabledTriggerProps {
167 #[props(default)]
169 pub class: String,
170 #[props(default)]
172 pub style: String,
173 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#[derive(Clone, PartialEq, Props)]
224pub struct TooltipProps {
225 pub text: String,
227 #[props(default)]
229 pub placement: TooltipPlacement,
230 #[props(default)]
232 pub fallback_placements: Vec<TooltipPlacement>,
233 #[props(default)]
235 pub trigger: TooltipTriggers,
236 #[props(default)]
238 pub delay: TooltipDelay,
239 #[props(default)]
241 pub open: Option<bool>,
242 #[props(default = OverlayOffset::TOOLTIP)]
244 pub offset: OverlayOffset,
245 #[props(default = 0.0)]
247 pub boundary_padding: f64,
248 #[props(default)]
250 pub class: String,
251 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 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 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 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 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
487const TOOLTIP_ARROW_HALF: f64 = 6.4;
490
491fn 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
563fn 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 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 let bottom = arrow_style(Some(pos), TooltipPlacement::Bottom);
723 assert!(bottom.contains("position: absolute"));
724 assert!(bottom.contains("left: 23.600px"));
725 let end = arrow_style(Some(pos), TooltipPlacement::End);
727 assert!(end.contains("top: 23.600px"));
728 assert_eq!(arrow_style(None, TooltipPlacement::Bottom), "");
730 }
731}