1use std::sync::atomic::{AtomicUsize, Ordering};
2
3use dioxus::core::{DynamicNode, TemplateNode, VNode};
4use dioxus::prelude::*;
5use gloo_timers::future::TimeoutFuture;
6
7use crate::overlay::{
8 OverlayOffset, OverlayPlacement, OverlayPosition, OverlayRect, calculate_overlay_position,
9};
10
11static NEXT_POPOVER_ID: AtomicUsize = AtomicUsize::new(1);
12static NEXT_POPOVER_TRIGGER_ID: AtomicUsize = AtomicUsize::new(1);
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16pub enum PopoverPlacement {
17 Auto,
19 Top,
21 Bottom,
23 Start,
25 #[default]
27 End,
28}
29
30impl PopoverPlacement {
31 fn class(self) -> &'static str {
32 match self {
33 PopoverPlacement::Auto | PopoverPlacement::End => "bs-popover-end",
34 PopoverPlacement::Top => "bs-popover-top",
35 PopoverPlacement::Bottom => "bs-popover-bottom",
36 PopoverPlacement::Start => "bs-popover-start",
37 }
38 }
39
40 fn data_value(self) -> &'static str {
41 match self {
42 PopoverPlacement::Auto => "auto",
43 PopoverPlacement::Top => "top",
44 PopoverPlacement::Bottom => "bottom",
45 PopoverPlacement::Start => "start",
46 PopoverPlacement::End => "end",
47 }
48 }
49}
50
51impl From<PopoverPlacement> for OverlayPlacement {
52 fn from(value: PopoverPlacement) -> Self {
53 match value {
54 PopoverPlacement::Auto => OverlayPlacement::Auto,
55 PopoverPlacement::Top => OverlayPlacement::Top,
56 PopoverPlacement::Bottom => OverlayPlacement::Bottom,
57 PopoverPlacement::Start => OverlayPlacement::Start,
58 PopoverPlacement::End => OverlayPlacement::End,
59 }
60 }
61}
62
63impl From<OverlayPlacement> for PopoverPlacement {
64 fn from(value: OverlayPlacement) -> Self {
65 match value {
66 OverlayPlacement::Auto => PopoverPlacement::Auto,
67 OverlayPlacement::Top => PopoverPlacement::Top,
68 OverlayPlacement::Bottom => PopoverPlacement::Bottom,
69 OverlayPlacement::Start => PopoverPlacement::Start,
70 OverlayPlacement::End => PopoverPlacement::End,
71 }
72 }
73}
74
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub struct PopoverTriggers {
78 pub hover: bool,
79 pub focus: bool,
80 pub click: bool,
81}
82
83impl PopoverTriggers {
84 pub const CLICK: Self = Self {
86 hover: false,
87 focus: false,
88 click: true,
89 };
90
91 pub const HOVER_FOCUS: Self = Self {
93 hover: true,
94 focus: true,
95 click: false,
96 };
97
98 pub const HOVER: Self = Self {
100 hover: true,
101 focus: false,
102 click: false,
103 };
104
105 pub const FOCUS: Self = Self {
107 hover: false,
108 focus: true,
109 click: false,
110 };
111
112 pub const MANUAL: Self = Self {
114 hover: false,
115 focus: false,
116 click: false,
117 };
118}
119
120impl Default for PopoverTriggers {
121 fn default() -> Self {
122 Self::CLICK
123 }
124}
125
126#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
128pub struct PopoverDelay {
129 pub show_ms: u32,
130 pub hide_ms: u32,
131}
132
133impl PopoverDelay {
134 pub const fn new(show_ms: u32, hide_ms: u32) -> Self {
135 Self { show_ms, hide_ms }
136 }
137}
138
139#[derive(Clone)]
140struct PopoverPositioning {
141 trigger_id: String,
142 popover_id: String,
143 overlay_position: Signal<Option<OverlayPosition>>,
144 placement: PopoverPlacement,
145 fallback_placements: Vec<PopoverPlacement>,
146 offset: OverlayOffset,
147 boundary_padding: f64,
148}
149
150#[derive(Clone, Copy)]
151struct PopoverInteractionSignals {
152 hover_active: Signal<bool>,
153 focus_active: Signal<bool>,
154 click_active: Signal<bool>,
155}
156
157#[derive(Clone, PartialEq, Props)]
163pub struct PopoverDisabledTriggerProps {
164 #[props(default)]
166 pub class: String,
167 #[props(default)]
169 pub style: String,
170 pub children: Element,
172}
173
174#[component]
175pub fn PopoverDisabledTrigger(props: PopoverDisabledTriggerProps) -> Element {
176 let style = if props.style.is_empty() {
177 "display: inline-flex;".to_string()
178 } else {
179 format!("display: inline-flex; {}", props.style)
180 };
181
182 rsx! {
183 span {
184 class: "{props.class}",
185 style,
186 tabindex: "0",
187 {props.children}
188 }
189 }
190}
191
192#[derive(Clone, PartialEq, Props)]
225pub struct PopoverProps {
226 #[props(default)]
228 pub title: String,
229 pub body: Element,
231 #[props(default)]
233 pub placement: PopoverPlacement,
234 #[props(default)]
236 pub fallback_placements: Vec<PopoverPlacement>,
237 #[props(default)]
239 pub trigger: PopoverTriggers,
240 #[props(default)]
242 pub delay: PopoverDelay,
243 #[props(default)]
245 pub open: Option<bool>,
246 #[props(default = OverlayOffset::POPOVER)]
248 pub offset: OverlayOffset,
249 #[props(default = 0.0)]
251 pub boundary_padding: f64,
252 #[props(default = true)]
254 pub dismiss_on_outside_click: bool,
255 #[props(default)]
257 pub class: String,
258 pub children: Element,
260}
261
262#[component]
263pub fn Popover(props: PopoverProps) -> Element {
264 let popover_id = use_signal(next_popover_id);
265 let trigger_id = use_signal(next_popover_trigger_id);
266 let overlay_position = use_signal(|| None::<OverlayPosition>);
267 let visible = use_signal(|| props.open.unwrap_or(false));
268 let visibility_revision = use_signal(|| 0_u64);
269 let mut hover_active = use_signal(|| false);
270 let mut focus_active = use_signal(|| false);
271 let mut click_active = use_signal(|| false);
272
273 let has_content = !props.title.is_empty() || element_has_content(&props.body);
274 let is_visible = has_content && props.open.unwrap_or(*visible.read());
275 let effective_placement = overlay_position
276 .read()
277 .as_ref()
278 .map(|position| PopoverPlacement::from(position.placement))
279 .unwrap_or(props.placement);
280 let placement_class = effective_placement.class();
281 let placement_value = effective_placement.data_value();
282 let popover_class = classes("popover fade show", placement_class, &props.class);
283 let popover_style = popover_style(*overlay_position.read());
284 let arrow_style = arrow_style(*overlay_position.read(), effective_placement);
285 let describedby = if is_visible {
286 popover_id.read().clone()
287 } else {
288 String::new()
289 };
290
291 let effect_has_content = has_content;
292 let positioning = PopoverPositioning {
293 trigger_id: trigger_id.read().clone(),
294 popover_id: popover_id.read().clone(),
295 overlay_position,
296 placement: props.placement,
297 fallback_placements: props.fallback_placements.clone(),
298 offset: props.offset,
299 boundary_padding: props.boundary_padding,
300 };
301 let effect_trigger_id = trigger_id.read().clone();
302 let effect_popover_id = popover_id.read().clone();
303 let mut effect_overlay_position = overlay_position;
304
305 let effect_positioning = positioning.clone();
306 use_effect(use_reactive(
307 (
308 &props.open,
309 &effect_has_content,
310 &props.placement,
311 &props.fallback_placements,
312 &props.offset,
313 &props.boundary_padding,
314 &effect_trigger_id,
315 &effect_popover_id,
316 ),
317 move |_| {
318 if !effect_has_content || props.open == Some(false) {
319 effect_overlay_position.set(None);
320 return;
321 }
322
323 if props.open == Some(true) || (props.open.is_none() && *visible.read()) {
324 measure_popover_position(effect_positioning.clone());
325 }
326 },
327 ));
328
329 let interactions = PopoverInteractionSignals {
330 hover_active,
331 focus_active,
332 click_active,
333 };
334
335 let enter_positioning = positioning.clone();
336 let leave_positioning = positioning.clone();
337 let focus_in_positioning = positioning.clone();
338 let focus_out_positioning = positioning.clone();
339 let click_positioning = positioning.clone();
340 let dismiss_positioning = positioning;
341 let should_render_backdrop =
342 is_visible && props.open.is_none() && props.dismiss_on_outside_click;
343
344 rsx! {
345 if should_render_backdrop {
346 div {
347 class: "popover-backdrop",
348 style: "position: fixed; inset: 0; z-index: 1069;",
349 onclick: move |_| {
350 hover_active.set(false);
351 focus_active.set(false);
352 click_active.set(false);
353 schedule_popover_visibility(
354 visible,
355 visibility_revision,
356 props.open,
357 props.trigger,
358 props.delay,
359 interactions,
360 dismiss_positioning.clone(),
361 );
362 },
363 }
364 }
365
366 span {
367 id: "{trigger_id}",
368 class: "popover-wrapper",
369 style: if is_visible { "position: relative; display: inline-flex; z-index: 1070;" } else { "position: relative; display: inline-flex;" },
375 "aria-describedby": "{describedby}",
376 onmouseenter: move |_| {
377 if props.trigger.hover && props.open.is_none() {
378 hover_active.set(true);
379 schedule_popover_visibility(
380 visible,
381 visibility_revision,
382 props.open,
383 props.trigger,
384 props.delay,
385 interactions,
386 enter_positioning.clone(),
387 );
388 }
389 },
390 onmouseleave: move |_| {
391 if props.trigger.hover && props.open.is_none() {
392 hover_active.set(false);
393 schedule_popover_visibility(
394 visible,
395 visibility_revision,
396 props.open,
397 props.trigger,
398 props.delay,
399 interactions,
400 leave_positioning.clone(),
401 );
402 }
403 },
404 onfocusin: move |_| {
405 if props.trigger.focus && props.open.is_none() {
406 focus_active.set(true);
407 schedule_popover_visibility(
408 visible,
409 visibility_revision,
410 props.open,
411 props.trigger,
412 props.delay,
413 interactions,
414 focus_in_positioning.clone(),
415 );
416 }
417 },
418 onfocusout: move |_| {
419 if props.trigger.focus && props.open.is_none() {
420 focus_active.set(false);
421 schedule_popover_visibility(
422 visible,
423 visibility_revision,
424 props.open,
425 props.trigger,
426 props.delay,
427 interactions,
428 focus_out_positioning.clone(),
429 );
430 }
431 },
432 onclick: move |evt| {
433 if props.trigger.click && props.open.is_none() {
434 evt.stop_propagation();
435 let next_click_active = {
436 let active = click_active.read();
437 !*active
438 };
439 click_active.set(next_click_active);
440 schedule_popover_visibility(
441 visible,
442 visibility_revision,
443 props.open,
444 props.trigger,
445 props.delay,
446 interactions,
447 click_positioning.clone(),
448 );
449 }
450 },
451
452 {props.children}
453
454 if is_visible {
455 div {
456 id: "{popover_id}",
457 class: "{popover_class}",
458 role: "tooltip",
459 "data-popper-placement": "{placement_value}",
460 style: "{popover_style}",
461 onclick: move |evt| evt.stop_propagation(),
462 div { class: "popover-arrow", style: "{arrow_style}" }
463 if !props.title.is_empty() {
464 h3 { class: "popover-header", "{props.title}" }
465 }
466 if element_has_content(&props.body) {
467 div { class: "popover-body", {props.body} }
468 }
469 }
470 }
471 }
472 }
473}
474
475fn next_popover_id() -> String {
476 let id = NEXT_POPOVER_ID.fetch_add(1, Ordering::Relaxed);
477 format!("dbcss-popover-{id}")
478}
479
480fn next_popover_trigger_id() -> String {
481 let id = NEXT_POPOVER_TRIGGER_ID.fetch_add(1, Ordering::Relaxed);
482 format!("dbcss-popover-trigger-{id}")
483}
484
485fn classes(base: &str, placement_class: &str, extra: &str) -> String {
486 if extra.is_empty() {
487 format!("{base} {placement_class}")
488 } else {
489 format!("{base} {placement_class} {extra}")
490 }
491}
492
493fn popover_style(position: Option<OverlayPosition>) -> String {
494 match position {
495 Some(position) => format!(
496 "position: fixed; left: {:.3}px; top: {:.3}px; z-index: 1070; visibility: visible;",
497 position.x, position.y
498 ),
499 None => "position: fixed; left: 0; top: 0; z-index: 1070; visibility: hidden;".to_string(),
500 }
501}
502
503fn arrow_style(position: Option<OverlayPosition>, placement: PopoverPlacement) -> String {
508 let Some(position) = position else {
509 return String::new();
510 };
511 let edge = position.arrow - 8.0;
519 match placement {
520 PopoverPlacement::Start | PopoverPlacement::End => {
521 format!("position: absolute; top: {edge:.3}px;")
522 }
523 _ => format!("position: absolute; left: {edge:.3}px;"),
524 }
525}
526
527fn schedule_popover_visibility(
528 mut visible: Signal<bool>,
529 mut revision: Signal<u64>,
530 open: Option<bool>,
531 trigger: PopoverTriggers,
532 delay: PopoverDelay,
533 interactions: PopoverInteractionSignals,
534 mut positioning: PopoverPositioning,
535) {
536 if open.is_some() {
537 return;
538 }
539
540 let next_revision = *revision.read() + 1;
541 revision.set(next_revision);
542 let should_show = (trigger.hover && *interactions.hover_active.read())
543 || (trigger.focus && *interactions.focus_active.read())
544 || (trigger.click && *interactions.click_active.read());
545 let delay_ms = if should_show {
546 delay.show_ms
547 } else {
548 delay.hide_ms
549 };
550
551 spawn(async move {
552 if delay_ms > 0 {
553 TimeoutFuture::new(delay_ms).await;
554 }
555
556 if *revision.read() == next_revision {
557 visible.set(should_show);
558 if should_show {
559 measure_popover_position(positioning);
560 } else {
561 positioning.overlay_position.set(None);
562 }
563 }
564 });
565}
566
567fn measure_popover_position(mut positioning: PopoverPositioning) {
568 spawn(async move {
569 let Some(trigger_rect) = element_rect(&positioning.trigger_id).await else {
570 return;
571 };
572 let Some(popover_rect) = element_rect(&positioning.popover_id).await else {
573 return;
574 };
575 let Some(boundary) = viewport_boundary().await else {
576 return;
577 };
578
579 let fallback_placements = positioning
580 .fallback_placements
581 .into_iter()
582 .map(OverlayPlacement::from)
583 .collect::<Vec<_>>();
584
585 positioning
586 .overlay_position
587 .set(Some(calculate_overlay_position(
588 trigger_rect,
589 popover_rect,
590 boundary,
591 positioning.placement.into(),
592 &fallback_placements,
593 positioning.offset,
594 positioning.boundary_padding,
595 )));
596 });
597}
598
599async fn element_rect(id: &str) -> Option<OverlayRect> {
600 let id = format!("{id:?}");
601 let value = document::eval(&format!(
602 r#"
603 const element = document.getElementById({id});
604 if (!element) {{
605 return null;
606 }}
607 const rect = element.getBoundingClientRect();
608 return {{
609 x: rect.left,
610 y: rect.top,
611 width: rect.width,
612 height: rect.height
613 }};
614 "#
615 ))
616 .await
617 .ok()?;
618
619 if value.is_null() {
620 return None;
621 }
622
623 Some(OverlayRect::new(
624 value.get("x").and_then(|value| value.as_f64())?,
625 value.get("y").and_then(|value| value.as_f64())?,
626 value.get("width").and_then(|value| value.as_f64())?,
627 value.get("height").and_then(|value| value.as_f64())?,
628 ))
629}
630
631async fn viewport_boundary() -> Option<OverlayRect> {
632 let value = document::eval(
633 r#"
634 return {
635 x: 0,
636 y: 0,
637 width: window.innerWidth || document.documentElement.clientWidth || 0,
638 height: window.innerHeight || document.documentElement.clientHeight || 0
639 };
640 "#,
641 )
642 .await
643 .ok()?;
644
645 let rect = OverlayRect::new(
646 value.get("x").and_then(|value| value.as_f64())?,
647 value.get("y").and_then(|value| value.as_f64())?,
648 value.get("width").and_then(|value| value.as_f64())?,
649 value.get("height").and_then(|value| value.as_f64())?,
650 );
651 if rect.width <= 0.0 || rect.height <= 0.0 {
652 return None;
653 }
654
655 Some(rect)
656}
657
658fn element_has_content(element: &Element) -> bool {
659 let Ok(vnode) = element else {
660 return false;
661 };
662
663 vnode_has_content(vnode)
664}
665
666fn vnode_has_content(vnode: &VNode) -> bool {
667 vnode
668 .template
669 .roots
670 .iter()
671 .any(template_node_has_static_content)
672 || vnode.dynamic_nodes.iter().any(dynamic_node_has_content)
673}
674
675fn template_node_has_static_content(node: &TemplateNode) -> bool {
676 match node {
677 TemplateNode::Element { .. } => true,
678 TemplateNode::Text { text } => !text.is_empty(),
679 TemplateNode::Dynamic { .. } => false,
680 }
681}
682
683fn dynamic_node_has_content(node: &DynamicNode) -> bool {
684 match node {
685 DynamicNode::Component(_) => true,
686 DynamicNode::Text(text) => !text.value.is_empty(),
687 DynamicNode::Placeholder(_) => false,
688 DynamicNode::Fragment(nodes) => nodes.iter().any(vnode_has_content),
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn trigger_defaults_to_click() {
698 let triggers = PopoverTriggers::default();
699 assert!(!triggers.hover);
700 assert!(!triggers.focus);
701 assert!(triggers.click);
702 }
703
704 #[test]
705 fn placement_defaults_to_bootstrap_end() {
706 assert_eq!(PopoverPlacement::default(), PopoverPlacement::End);
707 }
708
709 #[test]
710 fn placement_converts_to_overlay_placement() {
711 assert_eq!(
712 OverlayPlacement::from(PopoverPlacement::Auto),
713 OverlayPlacement::Auto
714 );
715 assert_eq!(
716 OverlayPlacement::from(PopoverPlacement::Bottom),
717 OverlayPlacement::Bottom
718 );
719 }
720
721 #[test]
722 fn placement_classes_match_bootstrap() {
723 assert_eq!(PopoverPlacement::Top.class(), "bs-popover-top");
724 assert_eq!(PopoverPlacement::Bottom.class(), "bs-popover-bottom");
725 assert_eq!(PopoverPlacement::Start.class(), "bs-popover-start");
726 assert_eq!(PopoverPlacement::End.class(), "bs-popover-end");
727 }
728}