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-block;".to_string()
178 } else {
179 format!("display: inline-block; {}", 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 describedby = if is_visible {
285 popover_id.read().clone()
286 } else {
287 String::new()
288 };
289
290 let effect_has_content = has_content;
291 let positioning = PopoverPositioning {
292 trigger_id: trigger_id.read().clone(),
293 popover_id: popover_id.read().clone(),
294 overlay_position,
295 placement: props.placement,
296 fallback_placements: props.fallback_placements.clone(),
297 offset: props.offset,
298 boundary_padding: props.boundary_padding,
299 };
300 let effect_trigger_id = trigger_id.read().clone();
301 let effect_popover_id = popover_id.read().clone();
302 let mut effect_overlay_position = overlay_position;
303
304 let effect_positioning = positioning.clone();
305 use_effect(use_reactive(
306 (
307 &props.open,
308 &effect_has_content,
309 &props.placement,
310 &props.fallback_placements,
311 &props.offset,
312 &props.boundary_padding,
313 &effect_trigger_id,
314 &effect_popover_id,
315 ),
316 move |_| {
317 if !effect_has_content || props.open == Some(false) {
318 effect_overlay_position.set(None);
319 return;
320 }
321
322 if props.open == Some(true) || (props.open.is_none() && *visible.read()) {
323 measure_popover_position(effect_positioning.clone());
324 }
325 },
326 ));
327
328 let interactions = PopoverInteractionSignals {
329 hover_active,
330 focus_active,
331 click_active,
332 };
333
334 let enter_positioning = positioning.clone();
335 let leave_positioning = positioning.clone();
336 let focus_in_positioning = positioning.clone();
337 let focus_out_positioning = positioning.clone();
338 let click_positioning = positioning.clone();
339 let dismiss_positioning = positioning;
340 let should_render_backdrop =
341 is_visible && props.open.is_none() && props.dismiss_on_outside_click;
342
343 rsx! {
344 if should_render_backdrop {
345 div {
346 class: "popover-backdrop",
347 style: "position: fixed; inset: 0; z-index: 1069;",
348 onclick: move |_| {
349 hover_active.set(false);
350 focus_active.set(false);
351 click_active.set(false);
352 schedule_popover_visibility(
353 visible,
354 visibility_revision,
355 props.open,
356 props.trigger,
357 props.delay,
358 interactions,
359 dismiss_positioning.clone(),
360 );
361 },
362 }
363 }
364
365 span {
366 id: "{trigger_id}",
367 class: "popover-wrapper",
368 style: if is_visible { "position: relative; display: inline-block; z-index: 1070;" } else { "position: relative; display: inline-block;" },
369 "aria-describedby": "{describedby}",
370 onmouseenter: move |_| {
371 if props.trigger.hover && props.open.is_none() {
372 hover_active.set(true);
373 schedule_popover_visibility(
374 visible,
375 visibility_revision,
376 props.open,
377 props.trigger,
378 props.delay,
379 interactions,
380 enter_positioning.clone(),
381 );
382 }
383 },
384 onmouseleave: move |_| {
385 if props.trigger.hover && props.open.is_none() {
386 hover_active.set(false);
387 schedule_popover_visibility(
388 visible,
389 visibility_revision,
390 props.open,
391 props.trigger,
392 props.delay,
393 interactions,
394 leave_positioning.clone(),
395 );
396 }
397 },
398 onfocusin: move |_| {
399 if props.trigger.focus && props.open.is_none() {
400 focus_active.set(true);
401 schedule_popover_visibility(
402 visible,
403 visibility_revision,
404 props.open,
405 props.trigger,
406 props.delay,
407 interactions,
408 focus_in_positioning.clone(),
409 );
410 }
411 },
412 onfocusout: move |_| {
413 if props.trigger.focus && props.open.is_none() {
414 focus_active.set(false);
415 schedule_popover_visibility(
416 visible,
417 visibility_revision,
418 props.open,
419 props.trigger,
420 props.delay,
421 interactions,
422 focus_out_positioning.clone(),
423 );
424 }
425 },
426 onclick: move |evt| {
427 if props.trigger.click && props.open.is_none() {
428 evt.stop_propagation();
429 let next_click_active = {
430 let active = click_active.read();
431 !*active
432 };
433 click_active.set(next_click_active);
434 schedule_popover_visibility(
435 visible,
436 visibility_revision,
437 props.open,
438 props.trigger,
439 props.delay,
440 interactions,
441 click_positioning.clone(),
442 );
443 }
444 },
445
446 {props.children}
447
448 if is_visible {
449 div {
450 id: "{popover_id}",
451 class: "{popover_class}",
452 role: "tooltip",
453 "data-popper-placement": "{placement_value}",
454 style: "{popover_style}",
455 onclick: move |evt| evt.stop_propagation(),
456 div { class: "popover-arrow" }
457 if !props.title.is_empty() {
458 h3 { class: "popover-header", "{props.title}" }
459 }
460 if element_has_content(&props.body) {
461 div { class: "popover-body", {props.body} }
462 }
463 }
464 }
465 }
466 }
467}
468
469fn next_popover_id() -> String {
470 let id = NEXT_POPOVER_ID.fetch_add(1, Ordering::Relaxed);
471 format!("dbcss-popover-{id}")
472}
473
474fn next_popover_trigger_id() -> String {
475 let id = NEXT_POPOVER_TRIGGER_ID.fetch_add(1, Ordering::Relaxed);
476 format!("dbcss-popover-trigger-{id}")
477}
478
479fn classes(base: &str, placement_class: &str, extra: &str) -> String {
480 if extra.is_empty() {
481 format!("{base} {placement_class}")
482 } else {
483 format!("{base} {placement_class} {extra}")
484 }
485}
486
487fn popover_style(position: Option<OverlayPosition>) -> String {
488 match position {
489 Some(position) => format!(
490 "position: fixed; left: {:.3}px; top: {:.3}px; z-index: 1070; visibility: visible;",
491 position.x, position.y
492 ),
493 None => "position: fixed; left: 0; top: 0; z-index: 1070; visibility: hidden;".to_string(),
494 }
495}
496
497fn schedule_popover_visibility(
498 mut visible: Signal<bool>,
499 mut revision: Signal<u64>,
500 open: Option<bool>,
501 trigger: PopoverTriggers,
502 delay: PopoverDelay,
503 interactions: PopoverInteractionSignals,
504 mut positioning: PopoverPositioning,
505) {
506 if open.is_some() {
507 return;
508 }
509
510 let next_revision = *revision.read() + 1;
511 revision.set(next_revision);
512 let should_show = (trigger.hover && *interactions.hover_active.read())
513 || (trigger.focus && *interactions.focus_active.read())
514 || (trigger.click && *interactions.click_active.read());
515 let delay_ms = if should_show {
516 delay.show_ms
517 } else {
518 delay.hide_ms
519 };
520
521 spawn(async move {
522 if delay_ms > 0 {
523 TimeoutFuture::new(delay_ms).await;
524 }
525
526 if *revision.read() == next_revision {
527 visible.set(should_show);
528 if should_show {
529 measure_popover_position(positioning);
530 } else {
531 positioning.overlay_position.set(None);
532 }
533 }
534 });
535}
536
537fn measure_popover_position(mut positioning: PopoverPositioning) {
538 spawn(async move {
539 let Some(trigger_rect) = element_rect(&positioning.trigger_id).await else {
540 return;
541 };
542 let Some(popover_rect) = element_rect(&positioning.popover_id).await else {
543 return;
544 };
545 let Some(boundary) = viewport_boundary().await else {
546 return;
547 };
548
549 let fallback_placements = positioning
550 .fallback_placements
551 .into_iter()
552 .map(OverlayPlacement::from)
553 .collect::<Vec<_>>();
554
555 positioning
556 .overlay_position
557 .set(Some(calculate_overlay_position(
558 trigger_rect,
559 popover_rect,
560 boundary,
561 positioning.placement.into(),
562 &fallback_placements,
563 positioning.offset,
564 positioning.boundary_padding,
565 )));
566 });
567}
568
569async fn element_rect(id: &str) -> Option<OverlayRect> {
570 let id = format!("{id:?}");
571 let value = document::eval(&format!(
572 r#"
573 const element = document.getElementById({id});
574 if (!element) {{
575 return null;
576 }}
577 const rect = element.getBoundingClientRect();
578 return {{
579 x: rect.left,
580 y: rect.top,
581 width: rect.width,
582 height: rect.height
583 }};
584 "#
585 ))
586 .await
587 .ok()?;
588
589 if value.is_null() {
590 return None;
591 }
592
593 Some(OverlayRect::new(
594 value.get("x").and_then(|value| value.as_f64())?,
595 value.get("y").and_then(|value| value.as_f64())?,
596 value.get("width").and_then(|value| value.as_f64())?,
597 value.get("height").and_then(|value| value.as_f64())?,
598 ))
599}
600
601async fn viewport_boundary() -> Option<OverlayRect> {
602 let value = document::eval(
603 r#"
604 return {
605 x: 0,
606 y: 0,
607 width: window.innerWidth || document.documentElement.clientWidth || 0,
608 height: window.innerHeight || document.documentElement.clientHeight || 0
609 };
610 "#,
611 )
612 .await
613 .ok()?;
614
615 let rect = OverlayRect::new(
616 value.get("x").and_then(|value| value.as_f64())?,
617 value.get("y").and_then(|value| value.as_f64())?,
618 value.get("width").and_then(|value| value.as_f64())?,
619 value.get("height").and_then(|value| value.as_f64())?,
620 );
621 if rect.width <= 0.0 || rect.height <= 0.0 {
622 return None;
623 }
624
625 Some(rect)
626}
627
628fn element_has_content(element: &Element) -> bool {
629 let Ok(vnode) = element else {
630 return false;
631 };
632
633 vnode_has_content(vnode)
634}
635
636fn vnode_has_content(vnode: &VNode) -> bool {
637 vnode
638 .template
639 .roots
640 .iter()
641 .any(template_node_has_static_content)
642 || vnode.dynamic_nodes.iter().any(dynamic_node_has_content)
643}
644
645fn template_node_has_static_content(node: &TemplateNode) -> bool {
646 match node {
647 TemplateNode::Element { .. } => true,
648 TemplateNode::Text { text } => !text.is_empty(),
649 TemplateNode::Dynamic { .. } => false,
650 }
651}
652
653fn dynamic_node_has_content(node: &DynamicNode) -> bool {
654 match node {
655 DynamicNode::Component(_) => true,
656 DynamicNode::Text(text) => !text.value.is_empty(),
657 DynamicNode::Placeholder(_) => false,
658 DynamicNode::Fragment(nodes) => nodes.iter().any(vnode_has_content),
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn trigger_defaults_to_click() {
668 let triggers = PopoverTriggers::default();
669 assert!(!triggers.hover);
670 assert!(!triggers.focus);
671 assert!(triggers.click);
672 }
673
674 #[test]
675 fn placement_defaults_to_bootstrap_end() {
676 assert_eq!(PopoverPlacement::default(), PopoverPlacement::End);
677 }
678
679 #[test]
680 fn placement_converts_to_overlay_placement() {
681 assert_eq!(
682 OverlayPlacement::from(PopoverPlacement::Auto),
683 OverlayPlacement::Auto
684 );
685 assert_eq!(
686 OverlayPlacement::from(PopoverPlacement::Bottom),
687 OverlayPlacement::Bottom
688 );
689 }
690
691 #[test]
692 fn placement_classes_match_bootstrap() {
693 assert_eq!(PopoverPlacement::Top.class(), "bs-popover-top");
694 assert_eq!(PopoverPlacement::Bottom.class(), "bs-popover-bottom");
695 assert_eq!(PopoverPlacement::Start.class(), "bs-popover-start");
696 assert_eq!(PopoverPlacement::End.class(), "bs-popover-end");
697 }
698}