Skip to main content

dioxus_bootstrap_css/
popover.rs

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/// Popover placement.
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub enum PopoverPlacement {
18    /// Choose the first fallback placement that fits the viewport.
19    Auto,
20    /// Place popover above trigger.
21    Top,
22    /// Place popover below trigger.
23    Bottom,
24    /// Place popover before trigger in the inline axis.
25    Start,
26    /// Place popover after trigger in the inline axis.
27    #[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/// Popover trigger behavior.
77#[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    /// Click-only trigger.
86    pub const CLICK: Self = Self {
87        hover: false,
88        focus: false,
89        click: true,
90    };
91
92    /// Hover plus focus triggers.
93    pub const HOVER_FOCUS: Self = Self {
94        hover: true,
95        focus: true,
96        click: false,
97    };
98
99    /// Hover-only trigger.
100    pub const HOVER: Self = Self {
101        hover: true,
102        focus: false,
103        click: false,
104    };
105
106    /// Focus-only trigger.
107    pub const FOCUS: Self = Self {
108        hover: false,
109        focus: true,
110        click: false,
111    };
112
113    /// No internal trigger; use the `open` prop.
114    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/// Show/hide delay in milliseconds.
128#[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/// Wrapper helper for disabled controls that need a popover trigger.
159///
160/// Bootstrap documents disabled elements as non-interactive. Wrap a disabled
161/// button or input in this component so the wrapper can receive focus and
162/// pointer events while preserving the disabled child.
163#[derive(Clone, PartialEq, Props)]
164pub struct PopoverDisabledTriggerProps {
165    /// Additional CSS classes for the wrapper.
166    #[props(default)]
167    pub class: String,
168    /// Additional inline style for the wrapper.
169    #[props(default)]
170    pub style: String,
171    /// Disabled child element.
172    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/// Bootstrap Popover component.
194///
195/// Renders Bootstrap popover markup with Dioxus-owned trigger state and
196/// viewport-aware placement. It does not depend on Bootstrap JavaScript or
197/// Popper.js.
198///
199/// # Bootstrap HTML -> Dioxus
200///
201/// ```html
202/// <button data-bs-toggle="popover" data-bs-title="Title" data-bs-content="Content">Click</button>
203/// ```
204///
205/// ```rust,no_run
206/// # use dioxus::prelude::*;
207/// # use dioxus_bootstrap_css::prelude::*;
208/// # fn _doctest() -> Element {
209/// rsx! {
210///     Popover {
211///         title: "Popover Title",
212///         body: rsx! { "Rich content here." },
213///         Button { color: Color::Info, "Click for details" }
214///     }
215///     Popover {
216///         title: "Focus dismiss",
217///         body: rsx! { "Focus another element to dismiss." },
218///         trigger: PopoverTriggers::FOCUS,
219///         placement: PopoverPlacement::Auto,
220///         Button { color: Color::Secondary, "Focus" }
221///     }
222/// }
223/// # }
224/// ```
225#[derive(Clone, PartialEq, Props)]
226pub struct PopoverProps {
227    /// Popover title text.
228    #[props(default)]
229    pub title: String,
230    /// Popover body content.
231    pub body: Element,
232    /// Requested placement relative to the trigger element.
233    #[props(default)]
234    pub placement: PopoverPlacement,
235    /// Fallback placements used when requested placement does not fit.
236    #[props(default)]
237    pub fallback_placements: Vec<PopoverPlacement>,
238    /// Trigger behavior. Defaults to click.
239    #[props(default)]
240    pub trigger: PopoverTriggers,
241    /// Show/hide delay in milliseconds.
242    #[props(default)]
243    pub delay: PopoverDelay,
244    /// Controlled open state. When set, internal triggers are ignored.
245    #[props(default)]
246    pub open: Option<bool>,
247    /// Offset from trigger.
248    #[props(default = OverlayOffset::POPOVER)]
249    pub offset: OverlayOffset,
250    /// Padding inside viewport boundary.
251    #[props(default = 0.0)]
252    pub boundary_padding: f64,
253    /// Close uncontrolled popovers when clicking outside the trigger/popover.
254    #[props(default = true)]
255    pub dismiss_on_outside_click: bool,
256    /// Additional CSS classes for the popover element.
257    #[props(default)]
258    pub class: String,
259    /// Child element used as the trigger.
260    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    // Each effect run claims a generation; a stale loop exits, so repeated runs
309    // cannot stack watchers on one overlay.
310    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                // See `tooltip.rs`: a fixed-position box does not follow its trigger
333                // when the page scrolls, and one opened while the trigger was off
334                // screen needs a second chance to be placed.
335                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            // `inline-flex`, not `inline-block`: an inline-block wrapper carries
381            // line-box leading (descent below the baseline), so its measured box
382            // extends past the trigger and the popover anchors a dozen-odd pixels
383            // low. A flex wrapper has no line box, so it hugs the trigger and the
384            // popover lands where Popper would put it against the element itself.
385            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        // See `tooltip_style`: hidden, not unmounted, so it stays measurable and can
507        // return when the trigger scrolls back into view.
508        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
520/// Inline style that slides the `.popover-arrow` along the popover's cross axis so
521/// it stays pointing at the trigger even after the popover box is clamped to the
522/// viewport. Without this the arrow sits at its static position and misses the
523/// trigger whenever the box is shifted — the gap left by not running Popper.js.
524fn arrow_style(position: Option<OverlayPosition>, placement: PopoverPlacement) -> String {
525    let Some(position) = position else {
526        return String::new();
527    };
528    // `position.arrow` is the arrow centre in popover-local coordinates; the arrow
529    // box is 1rem (16px), so its leading edge is the centre minus half that.
530    //
531    // `position: absolute` is required: Bootstrap positions the arrow along the main
532    // edge (its `.bs-popover-*>.popover-arrow{top/bottom/left/right}` rules) but only
533    // Popper.js makes the element absolutely positioned. Without it those rules and
534    // this cross-axis offset are both ignored, so we set it here.
535    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
584/// Keep an open popover glued to its trigger for as long as it stays open.
585fn 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}