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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub enum TooltipPlacement {
16 Auto,
18 #[default]
20 Top,
21 Bottom,
23 Start,
25 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#[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 pub const HOVER_FOCUS: Self = Self {
88 hover: true,
89 focus: true,
90 click: false,
91 };
92
93 pub const HOVER: Self = Self {
95 hover: true,
96 focus: false,
97 click: false,
98 };
99
100 pub const FOCUS: Self = Self {
102 hover: false,
103 focus: true,
104 click: false,
105 };
106
107 pub const CLICK: Self = Self {
109 hover: false,
110 focus: false,
111 click: true,
112 };
113
114 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#[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#[derive(Clone, PartialEq, Props)]
165pub struct TooltipDisabledTriggerProps {
166 #[props(default)]
168 pub class: String,
169 #[props(default)]
171 pub style: String,
172 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#[derive(Clone, PartialEq, Props)]
223pub struct TooltipProps {
224 pub text: String,
226 #[props(default)]
228 pub placement: TooltipPlacement,
229 #[props(default)]
231 pub fallback_placements: Vec<TooltipPlacement>,
232 #[props(default)]
234 pub trigger: TooltipTriggers,
235 #[props(default)]
237 pub delay: TooltipDelay,
238 #[props(default)]
240 pub open: Option<bool>,
241 #[props(default = OverlayOffset::TOOLTIP)]
243 pub offset: OverlayOffset,
244 #[props(default = 0.0)]
246 pub boundary_padding: f64,
247 #[props(default)]
249 pub class: String,
250 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 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
466const TOOLTIP_ARROW_HALF: f64 = 6.4;
469
470fn 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 let bottom = arrow_style(Some(pos), TooltipPlacement::Bottom);
678 assert!(bottom.contains("position: absolute"));
679 assert!(bottom.contains("left: 23.600px"));
680 let end = arrow_style(Some(pos), TooltipPlacement::End);
682 assert!(end.contains("top: 23.600px"));
683 assert_eq!(arrow_style(None, TooltipPlacement::Bottom), "");
685 }
686}