Skip to main content

fc_dioxus/components/
popover.rs

1//! Native popovers and tooltips, from popover.css.
2use crate::{
3    core::{
4        assets,
5        attrs::{component_attributes, dismissed_attribute},
6    },
7    prelude::*,
8    theme::use_stylesheets,
9};
10const POPOVER: Bem = Bem::new("fc-popover");
11const TOOLTIP: Bem = Bem::new("fc-tooltip");
12const BUBBLE: Bem = Bem::new("fc-tooltip__bubble");
13fc_modifier! { enum BubbleSide for BUBBLE { Above => None, Below => "below" } }
14
15/// Non-modal floating content on the native popover API. Open it from a button with
16/// `popovertarget: "{id}"`; the platform supplies the top layer, light dismiss, Escape,
17/// and focus return.
18#[component]
19pub fn Popover(
20    id: Id,
21    #[props(default)] title: Option<Element>,
22    #[props(default)] footer: Option<Element>,
23    #[props(default)] class: Option<String>,
24    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
25    children: Element,
26) -> Element {
27    let mut attributes = component_attributes(
28        attributes,
29        ClassList::new(POPOVER.block()).extend(class.as_deref()),
30        &["id", "popover"],
31    );
32    attributes.push(Attribute::new("popover", "auto", None, false));
33    rsx! {
34        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_POPOVER_CSS } }
35        div { id: "{id}", ..attributes,
36            if let Some(title) = title { p { class: POPOVER.element("title"), {title} } }
37            {children}
38            if let Some(footer) = footer { div { class: POPOVER.element("footer"), {footer} } }
39        }
40    }
41}
42
43/// The bubble a [`Button`] inside a [`Tooltip`] is described by.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub(crate) struct DescribedBy(pub(crate) Id);
46
47/// A short supplementary label shown on hover and keyboard focus.
48///
49/// The [`Button`] inside is described by the bubble automatically. Escape hides the
50/// bubble without moving focus or the pointer (WCAG 1.4.13); leaving the control lets it
51/// show again. Never put information only here: the bubble repeats or clarifies a name.
52#[component]
53pub fn Tooltip(
54    tip: Element,
55    /// Show the bubble below the control instead of above.
56    #[props(default)]
57    below: bool,
58    /// Start dismissed, as if Escape had been pressed, e.g. when restoring a view.
59    #[props(default)]
60    dismissed: bool,
61    #[props(default)] class: Option<String>,
62    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
63    children: Element,
64) -> Element {
65    let id = use_id();
66    use_context_provider(|| DescribedBy(id.clone()));
67    let mut dismissed = use_signal(|| dismissed);
68    let mut attributes = component_attributes(
69        attributes,
70        ClassList::new(TOOLTIP.block()).extend(class.as_deref()),
71        &[],
72    );
73    attributes.extend(dismissed_attribute(dismissed()));
74    let bubble = ClassList::new(BUBBLE.block())
75        .with_modifier(
76            BUBBLE,
77            if below {
78                BubbleSide::Below
79            } else {
80                BubbleSide::Above
81            },
82        )
83        .to_string();
84    rsx! {
85        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_POPOVER_CSS } }
86        span {
87            onkeydown: move |event| {
88                // Only swallow Escape when it hid something, so it still closes a dialog
89                // the tooltip sits in.
90                if event.key() == Key::Escape && !dismissed() {
91                    dismissed.set(true);
92                    event.stop_propagation();
93                }
94            },
95            onpointerleave: move |_| dismissed.set(false),
96            onfocusout: move |_| dismissed.set(false),
97            ..attributes,
98            {children}
99            span { class: bubble, role: "tooltip", id: "{id}", {tip} }
100        }
101    }
102}