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