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};
10
11static NEXT_POPOVER_ID: AtomicUsize = AtomicUsize::new(1);
12static NEXT_POPOVER_TRIGGER_ID: AtomicUsize = AtomicUsize::new(1);
13
14/// Popover placement.
15#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16pub enum PopoverPlacement {
17    /// Choose the first fallback placement that fits the viewport.
18    Auto,
19    /// Place popover above trigger.
20    Top,
21    /// Place popover below trigger.
22    Bottom,
23    /// Place popover before trigger in the inline axis.
24    Start,
25    /// Place popover after trigger in the inline axis.
26    #[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/// Popover trigger behavior.
76#[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    /// Click-only trigger.
85    pub const CLICK: Self = Self {
86        hover: false,
87        focus: false,
88        click: true,
89    };
90
91    /// Hover plus focus triggers.
92    pub const HOVER_FOCUS: Self = Self {
93        hover: true,
94        focus: true,
95        click: false,
96    };
97
98    /// Hover-only trigger.
99    pub const HOVER: Self = Self {
100        hover: true,
101        focus: false,
102        click: false,
103    };
104
105    /// Focus-only trigger.
106    pub const FOCUS: Self = Self {
107        hover: false,
108        focus: true,
109        click: false,
110    };
111
112    /// No internal trigger; use the `open` prop.
113    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/// Show/hide delay in milliseconds.
127#[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/// Wrapper helper for disabled controls that need a popover trigger.
158///
159/// Bootstrap documents disabled elements as non-interactive. Wrap a disabled
160/// button or input in this component so the wrapper can receive focus and
161/// pointer events while preserving the disabled child.
162#[derive(Clone, PartialEq, Props)]
163pub struct PopoverDisabledTriggerProps {
164    /// Additional CSS classes for the wrapper.
165    #[props(default)]
166    pub class: String,
167    /// Additional inline style for the wrapper.
168    #[props(default)]
169    pub style: String,
170    /// Disabled child element.
171    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/// Bootstrap Popover component.
193///
194/// Renders Bootstrap popover markup with Dioxus-owned trigger state and
195/// viewport-aware placement. It does not depend on Bootstrap JavaScript or
196/// Popper.js.
197///
198/// # Bootstrap HTML -> Dioxus
199///
200/// ```html
201/// <button data-bs-toggle="popover" data-bs-title="Title" data-bs-content="Content">Click</button>
202/// ```
203///
204/// ```rust,no_run
205/// # use dioxus::prelude::*;
206/// # use dioxus_bootstrap_css::prelude::*;
207/// # fn _doctest() -> Element {
208/// rsx! {
209///     Popover {
210///         title: "Popover Title",
211///         body: rsx! { "Rich content here." },
212///         Button { color: Color::Info, "Click for details" }
213///     }
214///     Popover {
215///         title: "Focus dismiss",
216///         body: rsx! { "Focus another element to dismiss." },
217///         trigger: PopoverTriggers::FOCUS,
218///         placement: PopoverPlacement::Auto,
219///         Button { color: Color::Secondary, "Focus" }
220///     }
221/// }
222/// # }
223/// ```
224#[derive(Clone, PartialEq, Props)]
225pub struct PopoverProps {
226    /// Popover title text.
227    #[props(default)]
228    pub title: String,
229    /// Popover body content.
230    pub body: Element,
231    /// Requested placement relative to the trigger element.
232    #[props(default)]
233    pub placement: PopoverPlacement,
234    /// Fallback placements used when requested placement does not fit.
235    #[props(default)]
236    pub fallback_placements: Vec<PopoverPlacement>,
237    /// Trigger behavior. Defaults to click.
238    #[props(default)]
239    pub trigger: PopoverTriggers,
240    /// Show/hide delay in milliseconds.
241    #[props(default)]
242    pub delay: PopoverDelay,
243    /// Controlled open state. When set, internal triggers are ignored.
244    #[props(default)]
245    pub open: Option<bool>,
246    /// Offset from trigger.
247    #[props(default = OverlayOffset::POPOVER)]
248    pub offset: OverlayOffset,
249    /// Padding inside viewport boundary.
250    #[props(default = 0.0)]
251    pub boundary_padding: f64,
252    /// Close uncontrolled popovers when clicking outside the trigger/popover.
253    #[props(default = true)]
254    pub dismiss_on_outside_click: bool,
255    /// Additional CSS classes for the popover element.
256    #[props(default)]
257    pub class: String,
258    /// Child element used as the trigger.
259    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            // `inline-flex`, not `inline-block`: an inline-block wrapper carries
370            // line-box leading (descent below the baseline), so its measured box
371            // extends past the trigger and the popover anchors a dozen-odd pixels
372            // low. A flex wrapper has no line box, so it hugs the trigger and the
373            // popover lands where Popper would put it against the element itself.
374            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
503/// Inline style that slides the `.popover-arrow` along the popover's cross axis so
504/// it stays pointing at the trigger even after the popover box is clamped to the
505/// viewport. Without this the arrow sits at its static position and misses the
506/// trigger whenever the box is shifted — the gap left by not running Popper.js.
507fn arrow_style(position: Option<OverlayPosition>, placement: PopoverPlacement) -> String {
508    let Some(position) = position else {
509        return String::new();
510    };
511    // `position.arrow` is the arrow centre in popover-local coordinates; the arrow
512    // box is 1rem (16px), so its leading edge is the centre minus half that.
513    //
514    // `position: absolute` is required: Bootstrap positions the arrow along the main
515    // edge (its `.bs-popover-*>.popover-arrow{top/bottom/left/right}` rules) but only
516    // Popper.js makes the element absolutely positioned. Without it those rules and
517    // this cross-axis offset are both ignored, so we set it here.
518    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}