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