Skip to main content

dioxus_ui_system/molecules/
dropdown_menu.rs

1//! Dropdown Menu molecule component
2//!
3//! Displays a menu to the user—such as a set of actions or functions—triggered by a button.
4//! Uses fixed positioning with portal-like behavior to escape parent overflow clipping.
5
6use crate::atoms::{Icon, IconColor, IconSize};
7use crate::styles::Style;
8use crate::theme::{use_style, use_theme};
9use dioxus::prelude::*;
10
11/// Dropdown menu item
12#[derive(Clone, PartialEq)]
13pub struct DropdownMenuItem {
14    /// Item label
15    pub label: String,
16    /// Item value
17    pub value: String,
18    /// Optional icon
19    pub icon: Option<String>,
20    /// Whether item is disabled
21    pub disabled: bool,
22    /// Keyboard shortcut (optional)
23    pub shortcut: Option<String>,
24}
25
26impl DropdownMenuItem {
27    /// Create a new dropdown menu item
28    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
29        Self {
30            label: label.into(),
31            value: value.into(),
32            icon: None,
33            disabled: false,
34            shortcut: None,
35        }
36    }
37
38    /// Add an icon
39    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
40        self.icon = Some(icon.into());
41        self
42    }
43
44    /// Set disabled state
45    pub fn disabled(mut self) -> Self {
46        self.disabled = true;
47        self
48    }
49
50    /// Add keyboard shortcut
51    pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
52        self.shortcut = Some(shortcut.into());
53        self
54    }
55}
56
57/// Dropdown menu properties
58#[derive(Props, Clone, PartialEq)]
59pub struct DropdownMenuProps {
60    /// Trigger element
61    pub trigger: Element,
62    /// Menu items
63    pub items: Vec<DropdownMenuItem>,
64    /// Callback when an item is selected
65    pub on_select: EventHandler<String>,
66    /// Menu alignment
67    #[props(default)]
68    pub align: DropdownAlign,
69    /// Custom inline styles
70    #[props(default)]
71    pub style: Option<String>,
72}
73
74/// Dropdown alignment
75#[derive(Default, Clone, PartialEq)]
76pub enum DropdownAlign {
77    /// Align to start (left)
78    #[default]
79    Start,
80    /// Align to end (right)
81    End,
82    /// Center alignment
83    Center,
84}
85
86/// Dropdown menu component
87///
88/// Uses a portal-like approach with fixed positioning to avoid being clipped by parent containers.
89/// The menu is rendered with position:fixed at calculated coordinates based on the trigger's position.
90#[component]
91pub fn DropdownMenu(props: DropdownMenuProps) -> Element {
92    let _theme = use_theme();
93    let mut is_open = use_signal(|| false);
94    let mut menu_position = use_signal(|| (0i32, 0i32));
95
96    let menu_base_style = use_style(|t| {
97        Style::new()
98            .rounded(&t.radius, "md")
99            .border(1, &t.colors.border)
100            .bg(&t.colors.popover)
101            .shadow(&t.shadows.lg)
102            .flex()
103            .flex_col()
104            .py(&t.spacing, "xs")
105            .z_index(9999)
106            .build()
107    });
108
109    // Store alignment for use in click handler
110    let align = props.align.clone();
111
112    let handle_trigger_click = move |event: Event<MouseData>| {
113        if !is_open() {
114            // Get the click coordinates in viewport space
115            let coords = event.data().page_coordinates();
116            let click_x = coords.x as i32;
117            let click_y = coords.y as i32;
118
119            // The menu width (used for alignment calculations)
120            let menu_width = 180;
121
122            // Calculate menu position based on alignment
123            // We position relative to the click, with some offset to show below the trigger
124            let (menu_x, menu_y) = match align {
125                DropdownAlign::Start => (click_x - 20, click_y + 20), // Click is somewhere in trigger, offset left
126                DropdownAlign::End => (click_x - menu_width + 20, click_y + 20), // Offset right
127                DropdownAlign::Center => (click_x - menu_width / 2, click_y + 20), // Center on click
128            };
129
130            // Ensure menu stays within viewport (with some padding)
131            let padding = 8;
132            let final_x = menu_x.max(padding);
133            let final_y = menu_y.max(padding);
134
135            menu_position.set((final_x, final_y));
136        }
137        is_open.toggle();
138    };
139
140    let (menu_x, menu_y) = menu_position();
141    let position_style = format!(
142        "position: fixed; left: {}px; top: {}px; width: 180px;",
143        menu_x, menu_y
144    );
145    let custom_style = props.style.clone().unwrap_or_default();
146
147    rsx! {
148        div {
149            style: "position: relative; display: inline-block;",
150
151            // Trigger
152            div {
153                onclick: handle_trigger_click,
154                {props.trigger}
155            }
156
157            // Overlay to close on outside click
158            if is_open() {
159                div {
160                    style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998;",
161                    onclick: move |_| is_open.set(false),
162                }
163            }
164
165            // Menu - rendered with fixed positioning to escape clipping
166            if is_open() {
167                div {
168                    style: "{menu_base_style} {position_style} {custom_style}",
169                    onclick: move |e| e.stop_propagation(),
170
171                    for item in props.items.clone() {
172                        DropdownMenuItemView {
173                            key: "{item.value}",
174                            item: item.clone(),
175                            on_select: props.on_select.clone(),
176                            on_close: move || is_open.set(false),
177                        }
178                    }
179                }
180            }
181        }
182    }
183}
184
185#[derive(Props, Clone, PartialEq)]
186struct DropdownMenuItemViewProps {
187    item: DropdownMenuItem,
188    on_select: EventHandler<String>,
189    on_close: EventHandler<()>,
190}
191
192#[component]
193fn DropdownMenuItemView(props: DropdownMenuItemViewProps) -> Element {
194    let _theme = use_theme();
195    let mut is_hovered = use_signal(|| false);
196
197    let item_style = use_style(move |t| {
198        let base = Style::new()
199            .w_full()
200            .flex()
201            .items_center()
202            .justify_between()
203            .gap(&t.spacing, "sm")
204            .px(&t.spacing, "sm")
205            .py(&t.spacing, "sm")
206            .rounded(&t.radius, "sm")
207            .text(&t.typography, "sm")
208            .cursor(if props.item.disabled {
209                "not-allowed"
210            } else {
211                "pointer"
212            })
213            .opacity(if props.item.disabled { 0.5 } else { 1.0 });
214
215        if is_hovered() && !props.item.disabled {
216            base.bg(&t.colors.accent).build()
217        } else {
218            base.build()
219        }
220    });
221
222    let value = props.item.value.clone();
223    let on_select = props.on_select.clone();
224    let on_close = props.on_close.clone();
225
226    rsx! {
227        div {
228            style: "{item_style}",
229            onmouseenter: move |_| is_hovered.set(true),
230            onmouseleave: move |_| is_hovered.set(false),
231            onclick: move |_| {
232                if !props.item.disabled {
233                    on_select.call(value.clone());
234                    on_close.call(());
235                }
236            },
237
238            // Label and icon
239            div {
240                style: "display: flex; align-items: center; gap: 8px;",
241                if let Some(icon) = &props.item.icon {
242                    Icon { name: icon.clone(), size: IconSize::Small, color: IconColor::Muted }
243                }
244                span { "{props.item.label}" }
245            }
246
247            // Shortcut
248            if let Some(shortcut) = &props.item.shortcut {
249                span {
250                    style: "font-size: 12px; color: rgb(148,163,184); margin-left: 24px;",
251                    "{shortcut}"
252                }
253            }
254        }
255    }
256}
257
258/// Dropdown menu separator
259#[component]
260pub fn DropdownMenuSeparator() -> Element {
261    let _theme = use_theme();
262
263    let separator_style = use_style(|t| {
264        Style::new()
265            .h_px(1)
266            .mx(&t.spacing, "sm")
267            .my(&t.spacing, "xs")
268            .bg(&t.colors.border)
269            .build()
270    });
271
272    rsx! {
273        div {
274            style: "{separator_style}",
275        }
276    }
277}
278
279/// Dropdown menu label
280#[derive(Props, Clone, PartialEq)]
281pub struct DropdownMenuLabelProps {
282    pub children: Element,
283}
284
285#[component]
286pub fn DropdownMenuLabel(props: DropdownMenuLabelProps) -> Element {
287    rsx! {
288        div {
289            style: "padding: 6px 8px; font-size: 12px; font-weight: 500; color: #64748b;",
290            {props.children}
291        }
292    }
293}