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-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/// 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 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}