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
5use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8
9/// Dropdown menu item
10#[derive(Clone, PartialEq)]
11pub struct DropdownMenuItem {
12    /// Item label
13    pub label: String,
14    /// Item value
15    pub value: String,
16    /// Optional icon
17    pub icon: Option<String>,
18    /// Whether item is disabled
19    pub disabled: bool,
20    /// Keyboard shortcut (optional)
21    pub shortcut: Option<String>,
22}
23
24impl DropdownMenuItem {
25    /// Create a new dropdown menu item
26    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
27        Self {
28            label: label.into(),
29            value: value.into(),
30            icon: None,
31            disabled: false,
32            shortcut: None,
33        }
34    }
35    
36    /// Add an icon
37    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
38        self.icon = Some(icon.into());
39        self
40    }
41    
42    /// Set disabled state
43    pub fn disabled(mut self, disabled: bool) -> Self {
44        self.disabled = disabled;
45        self
46    }
47    
48    /// Add keyboard shortcut
49    pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
50        self.shortcut = Some(shortcut.into());
51        self
52    }
53}
54
55/// Dropdown menu properties
56#[derive(Props, Clone, PartialEq)]
57pub struct DropdownMenuProps {
58    /// Trigger element
59    pub trigger: Element,
60    /// Menu items
61    pub items: Vec<DropdownMenuItem>,
62    /// Callback when an item is selected
63    pub on_select: EventHandler<String>,
64    /// Menu alignment
65    #[props(default)]
66    pub align: DropdownAlign,
67    /// Custom inline styles
68    #[props(default)]
69    pub style: Option<String>,
70}
71
72/// Dropdown alignment
73#[derive(Default, Clone, PartialEq)]
74pub enum DropdownAlign {
75    /// Align to start (left)
76    #[default]
77    Start,
78    /// Align to end (right)
79    End,
80    /// Center alignment
81    Center,
82}
83
84/// Dropdown menu component
85#[component]
86pub fn DropdownMenu(props: DropdownMenuProps) -> Element {
87    let _theme = use_theme();
88    let mut is_open = use_signal(|| false);
89    
90    let position = match props.align {
91        DropdownAlign::Start => "left: 0;",
92        DropdownAlign::End => "right: 0;",
93        DropdownAlign::Center => "left: 50%; transform: translateX(-50%);",
94    };
95    
96    let menu_style = use_style(|t| {
97        Style::new()
98            .absolute()
99            .top("calc(100% + 4px)")
100            .min_w_px(160)
101            .max_w_px(280)
102            .rounded(&t.radius, "md")
103            .border(1, &t.colors.border)
104            .bg(&t.colors.popover)
105            .shadow(&t.shadows.lg)
106            .flex()
107            .flex_col()
108            .p(&t.spacing, "xs")
109            .z_index(50)
110            .build()
111    });
112    
113    rsx! {
114        div {
115            style: "position: relative; display: inline-block;",
116            
117            // Trigger
118            div {
119                onclick: move |_| is_open.toggle(),
120                {props.trigger}
121            }
122            
123            // Menu
124            if is_open() {
125                // Overlay to close on outside click
126                div {
127                    style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 40;",
128                    onclick: move |_| is_open.set(false),
129                }
130                
131                div {
132                    style: "{menu_style} {position} {props.style.clone().unwrap_or_default()}",
133                    onclick: move |e| e.stop_propagation(),
134                    
135                    for item in props.items.clone() {
136                        DropdownMenuItemView {
137                            item: item.clone(),
138                            on_select: props.on_select.clone(),
139                            on_close: move || is_open.set(false),
140                        }
141                    }
142                }
143            }
144        }
145    }
146}
147
148#[derive(Props, Clone, PartialEq)]
149struct DropdownMenuItemViewProps {
150    item: DropdownMenuItem,
151    on_select: EventHandler<String>,
152    on_close: EventHandler<()>,
153}
154
155#[component]
156fn DropdownMenuItemView(props: DropdownMenuItemViewProps) -> Element {
157    let _theme = use_theme();
158    let mut is_hovered = use_signal(|| false);
159    
160    let item_style = use_style(move |t| {
161        let base = Style::new()
162            .w_full()
163            .flex()
164            .items_center()
165            .justify_between()
166            .px(&t.spacing, "sm")
167            .py(&t.spacing, "sm")
168            .rounded(&t.radius, "sm")
169            .text(&t.typography, "sm")
170            .cursor(if props.item.disabled { "not-allowed" } else { "pointer" })
171            .transition("all 100ms ease");
172        
173        if is_hovered() && !props.item.disabled {
174            base.bg(&t.colors.accent)
175                .text_color(&t.colors.accent_foreground)
176        } else {
177            base
178        }.build()
179    });
180    
181    let handle_click = move |_| {
182        if !props.item.disabled {
183            props.on_select.call(props.item.value.clone());
184            props.on_close.call(());
185        }
186    };
187    
188    rsx! {
189        button {
190            style: "{item_style} background: none; border: none; text-align: left; color: inherit;",
191            disabled: props.item.disabled,
192            onclick: handle_click,
193            onmouseenter: move |_| if !props.item.disabled { is_hovered.set(true) },
194            onmouseleave: move |_| is_hovered.set(false),
195            
196            div {
197                style: "display: flex; align-items: center; gap: 8px;",
198                
199                if let Some(icon) = props.item.icon.clone() {
200                    DropdownIcon { name: icon }
201                }
202                
203                span {
204                    style: if props.item.disabled { "opacity: 0.5;" } else { "" },
205                    "{props.item.label}"
206                }
207            }
208            
209            if let Some(shortcut) = props.item.shortcut.clone() {
210                span {
211                    style: "font-size: 11px; color: #94a3b8; margin-left: 24px;",
212                    "{shortcut}"
213                }
214            }
215        }
216    }
217}
218
219#[derive(Props, Clone, PartialEq)]
220struct DropdownIconProps {
221    name: String,
222}
223
224#[component]
225fn DropdownIcon(props: DropdownIconProps) -> Element {
226    rsx! {
227        svg {
228            view_box: "0 0 24 24",
229            fill: "none",
230            stroke: "currentColor",
231            stroke_width: "2",
232            stroke_linecap: "round",
233            stroke_linejoin: "round",
234            style: "width: 16px; height: 16px;",
235            
236            match props.name.as_str() {
237                "edit" => rsx! {
238                    path { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }
239                    path { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }
240                },
241                "copy" => rsx! {
242                    rect { x: "9", y: "9", width: "13", height: "13", rx: "2", ry: "2" }
243                    path { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" }
244                },
245                "trash" => rsx! {
246                    polyline { points: "3 6 5 6 21 6" }
247                    path { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }
248                },
249                _ => rsx! {
250                    circle { cx: "12", cy: "12", r: "10" }
251                },
252            }
253        }
254    }
255}
256
257/// Dropdown menu separator
258#[component]
259pub fn DropdownMenuSeparator() -> Element {
260    let _theme = use_theme();
261    
262    let separator_style = use_style(|t| {
263        Style::new()
264            .h_px(1)
265            .mx(&t.spacing, "sm")
266            .my(&t.spacing, "xs")
267            .bg(&t.colors.border)
268            .build()
269    });
270    
271    rsx! {
272        div {
273            style: "{separator_style}",
274        }
275    }
276}
277
278/// Dropdown menu label
279#[derive(Props, Clone, PartialEq)]
280pub struct DropdownMenuLabelProps {
281    pub children: Element,
282}
283
284#[component]
285pub fn DropdownMenuLabel(props: DropdownMenuLabelProps) -> Element {
286    rsx! {
287        div {
288            style: "padding: 6px 8px; font-size: 12px; font-weight: 500; color: #64748b;",
289            {props.children}
290        }
291    }
292}