Skip to main content

ui_storybook/components/menus/
popover_surface.rs

1//! `PopoverSurface` — popover chrome shared across every tray /
2//! dropdown / on-screen-options menu (M-UI.3 / AUT-123).
3
4use leptos::prelude::*;
5
6/// Where the popover anchors against its trigger. Drives a CSS class
7/// the parent overlay layer can use to position the surface; the
8/// component itself doesn't compute coordinates.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum PopoverPlacement {
11    /// Top-left of the trigger.
12    TopLeft,
13    /// Top-right of the trigger.
14    TopRight,
15    /// Bottom-left of the trigger. Default for tray menus.
16    #[default]
17    BottomLeft,
18    /// Bottom-right of the trigger.
19    BottomRight,
20    /// Centered in the viewport — for modal-style popovers.
21    Centered,
22}
23
24impl PopoverPlacement {
25    /// CSS class for the placement.
26    #[must_use]
27    pub fn css(self) -> &'static str {
28        match self {
29            PopoverPlacement::TopLeft => "popover-tl",
30            PopoverPlacement::TopRight => "popover-tr",
31            PopoverPlacement::BottomLeft => "popover-bl",
32            PopoverPlacement::BottomRight => "popover-br",
33            PopoverPlacement::Centered => "popover-center",
34        }
35    }
36}
37
38#[component]
39pub fn PopoverSurface(
40    /// Where the popover should anchor — drives a `popover-<placement>`
41    /// class but no positioning math.
42    #[prop(optional)]
43    placement: PopoverPlacement,
44    /// Width in CSS pixels. `None` lets content size itself.
45    #[prop(optional)]
46    width_px: Option<u16>,
47    /// Optional title rendered above the content with a small subtitle.
48    #[prop(optional, into)]
49    title: Option<String>,
50    /// Optional description under the title.
51    #[prop(optional, into)]
52    description: Option<String>,
53    /// Body content — typically a `MenuList`.
54    children: Children,
55    /// Optional bottom footer — primary action button or hint text.
56    #[prop(optional)]
57    footer: Option<Children>,
58) -> impl IntoView {
59    let style = width_px.map(|w| format!("width:{w}px")).unwrap_or_default();
60    let class = format!("popover-surface {}", placement.css());
61    view! {
62        <div class=class role="dialog" style=style>
63            {title.map(|t| view! {
64                <header class="popover-header">
65                    <div class="popover-title">{t}</div>
66                    {description.map(|d| view! { <div class="popover-description">{d}</div> })}
67                </header>
68            })}
69            <div class="popover-body">{children()}</div>
70            {footer.map(|f| view! { <footer class="popover-footer">{f()}</footer> })}
71        </div>
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn each_placement_has_unique_class() {
81        let classes = [
82            PopoverPlacement::TopLeft.css(),
83            PopoverPlacement::TopRight.css(),
84            PopoverPlacement::BottomLeft.css(),
85            PopoverPlacement::BottomRight.css(),
86            PopoverPlacement::Centered.css(),
87        ];
88        let mut sorted = classes.to_vec();
89        sorted.sort_unstable();
90        sorted.dedup();
91        assert_eq!(sorted.len(), classes.len());
92    }
93}