adui_dioxus/components/
menu.rs

1use dioxus::prelude::*;
2
3/// Menu display mode, aligned with Ant Design's `inline` and `horizontal` modes.
4#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5pub enum MenuMode {
6    #[default]
7    Inline,
8    Horizontal,
9}
10
11/// Data model for a single menu item.
12#[derive(Clone, PartialEq)]
13pub struct MenuItemNode {
14    pub id: String,
15    pub label: String,
16    pub icon: Option<Element>,
17    pub disabled: bool,
18    pub children: Option<Vec<MenuItemNode>>,
19}
20
21impl MenuItemNode {
22    pub fn leaf(id: impl Into<String>, label: impl Into<String>) -> Self {
23        Self {
24            id: id.into(),
25            label: label.into(),
26            icon: None,
27            disabled: false,
28            children: None,
29        }
30    }
31}
32
33/// Props for the Menu component (MVP subset).
34#[derive(Props, Clone, PartialEq)]
35pub struct MenuProps {
36    /// Menu items in a tree-like structure (MVP: at most two levels).
37    pub items: Vec<MenuItemNode>,
38    /// Display mode: inline (sider) or horizontal (header).
39    #[props(default)]
40    pub mode: MenuMode,
41    /// Controlled selected keys.
42    #[props(optional)]
43    pub selected_keys: Option<Vec<String>>,
44    /// Default selected keys (used when `selected_keys` is None).
45    #[props(optional)]
46    pub default_selected_keys: Option<Vec<String>>,
47    /// Controlled open keys (only meaningful in inline mode).
48    #[props(optional)]
49    pub open_keys: Option<Vec<String>>,
50    /// Default open keys for uncontrolled mode (inline).
51    #[props(optional)]
52    pub default_open_keys: Option<Vec<String>>,
53    /// Called when a leaf menu item is selected.
54    #[props(optional)]
55    pub on_select: Option<EventHandler<String>>,
56    /// Called when open keys change (inline mode).
57    #[props(optional)]
58    pub on_open_change: Option<EventHandler<Vec<String>>>,
59    /// When true, inline menu is collapsed (typically used with Sider).
60    #[props(default)]
61    pub inline_collapsed: bool,
62    #[props(optional)]
63    pub class: Option<String>,
64    #[props(optional)]
65    pub style: Option<String>,
66}
67
68/// Ant Design flavored Menu (MVP).
69#[component]
70pub fn Menu(props: MenuProps) -> Element {
71    let MenuProps {
72        items,
73        mode,
74        selected_keys,
75        default_selected_keys,
76        open_keys,
77        default_open_keys,
78        on_select,
79        on_open_change,
80        inline_collapsed,
81        class,
82        style,
83    } = props;
84
85    // Internal state for uncontrolled selected keys.
86    let selected_internal: Signal<Vec<String>> =
87        use_signal(|| default_selected_keys.unwrap_or_else(Vec::new));
88
89    // Internal state for uncontrolled open keys (inline mode only).
90    let open_internal: Signal<Vec<String>> =
91        use_signal(|| default_open_keys.unwrap_or_else(Vec::new));
92
93    let current_selected = selected_keys
94        .clone()
95        .unwrap_or_else(|| selected_internal.read().clone());
96    let current_open = open_keys
97        .clone()
98        .unwrap_or_else(|| open_internal.read().clone());
99
100    // Root classes.
101    let mut class_list = vec!["adui-menu".to_string()];
102    match mode {
103        MenuMode::Inline => class_list.push("adui-menu-inline".into()),
104        MenuMode::Horizontal => class_list.push("adui-menu-horizontal".into()),
105    }
106    if inline_collapsed && matches!(mode, MenuMode::Inline) {
107        class_list.push("adui-menu-inline-collapsed".into());
108    }
109    if let Some(extra) = class {
110        class_list.push(extra);
111    }
112    let class_attr = class_list.join(" ");
113    let style_attr = style.unwrap_or_default();
114
115    // Event handlers use cloned signals to update internal state when uncontrolled.
116    let on_select_cb = on_select;
117    let on_open_change_cb = on_open_change;
118    let selected_signal = selected_internal;
119    let open_signal = open_internal;
120    let is_selected_controlled = selected_keys.is_some();
121    let is_open_controlled = open_keys.is_some();
122
123    rsx! {
124        nav {
125            class: "{class_attr}",
126            style: "{style_attr}",
127            role: "menu",
128            ul {
129                class: "adui-menu-list",
130                {items.into_iter().map(|item| {
131                    let key = item.id.clone();
132                    let label = item.label.clone();
133                    let icon = item.icon.clone();
134                    let disabled = item.disabled;
135                    let children = item.children.clone().unwrap_or_default();
136                    let is_leaf = children.is_empty();
137                    let selected_snapshot = current_selected.clone();
138                    let open_snapshot = current_open.clone();
139                    let selected_signal_for_item = selected_signal;
140                    let open_signal_for_item = open_signal;
141                    let on_select_item = on_select_cb;
142                    let on_open_change_item = on_open_change_cb;
143
144                    let is_selected = selected_snapshot.contains(&key);
145                    let is_open = open_snapshot.contains(&key);
146
147                    rsx! {
148                        li {
149                            class: {
150                                let mut classes = vec!["adui-menu-item".to_string()];
151                                if !is_leaf {
152                                    classes.push("adui-menu-submenu".into());
153                                }
154                                if is_selected {
155                                    classes.push("adui-menu-item-selected".into());
156                                }
157                                if is_open && !inline_collapsed && matches!(mode, MenuMode::Inline) {
158                                    classes.push("adui-menu-submenu-open".into());
159                                }
160                                if disabled {
161                                    classes.push("adui-menu-item-disabled".into());
162                                }
163                                classes.join(" ")
164                            },
165                            role: "menuitem",
166                            onclick: move |_| {
167                                if disabled {
168                                    return;
169                                }
170                                if is_leaf {
171                                    // Update selected keys in uncontrolled mode.
172                                    if !is_selected_controlled {
173                                        let mut signal = selected_signal_for_item;
174                                        signal.set(vec![key.clone()]);
175                                    }
176                                    if let Some(cb) = on_select_item {
177                                        cb.call(key.clone());
178                                    }
179                                } else if matches!(mode, MenuMode::Inline) {
180                                    // Toggle open state for inline submenu.
181                                    let mut next = open_snapshot.clone();
182                                    if let Some(pos) = next.iter().position(|k| k == &key) {
183                                        next.remove(pos);
184                                    } else {
185                                        next.push(key.clone());
186                                    }
187                                    if !is_open_controlled {
188                                        let mut signal = open_signal_for_item;
189                                        signal.set(next.clone());
190                                    }
191                                    if let Some(cb) = on_open_change_item {
192                                        cb.call(next);
193                                    }
194                                }
195                            },
196                            div { class: "adui-menu-item-title",
197                                if let Some(icon_node) = icon {
198                                    span { class: "adui-menu-item-icon", {icon_node} }
199                                }
200                                span { class: "adui-menu-item-label", "{label}" }
201                            }
202                            if !children.is_empty() && matches!(mode, MenuMode::Inline) && !inline_collapsed {
203                                ul {
204                                    class: "adui-menu-submenu-list",
205                                    style: if is_open { "display: block;" } else { "display: none;" },
206                                    {children.into_iter().map(|child| {
207                                        let child_key = child.id.clone();
208                                        let child_label = child.label.clone();
209                                        let child_icon = child.icon.clone();
210                                        let child_disabled = child.disabled;
211                                        let selected_snapshot = selected_signal.read().clone();
212                                        let is_selected_child = selected_snapshot.contains(&child_key);
213                                        let selected_signal_child = selected_signal;
214                                        let on_select_child = on_select_cb;
215
216                                        rsx! {
217                                            li {
218                                                class: {
219                                                    let mut classes = vec!["adui-menu-item".to_string()];
220                                                    classes.push("adui-menu-submenu-item".into());
221                                                    if is_selected_child {
222                                                        classes.push("adui-menu-item-selected".into());
223                                                    }
224                                                    if child_disabled {
225                                                        classes.push("adui-menu-item-disabled".into());
226                                                    }
227                                                    classes.join(" ")
228                                                },
229                                                role: "menuitem",
230                                                onclick: move |_| {
231                                                    if child_disabled {
232                                                        return;
233                                                    }
234                                                    if !is_selected_controlled {
235                                                        let mut signal = selected_signal_child;
236                                                        signal.set(vec![child_key.clone()]);
237                                                    }
238                                                    if let Some(cb) = on_select_child {
239                                                        cb.call(child_key.clone());
240                                                    }
241                                                },
242                                                div { class: "adui-menu-item-title",
243                                                    if let Some(icon_node) = child_icon {
244                                                        span { class: "adui-menu-item-icon", {icon_node} }
245                                                    }
246                                                    span { class: "adui-menu-item-label", "{child_label}" }
247                                                }
248                                            }
249                                        }
250                                    })}
251                                }
252                            }
253                        }
254                    }
255                })}
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn menu_mode_default() {
267        assert_eq!(MenuMode::default(), MenuMode::Inline);
268    }
269
270    #[test]
271    fn menu_mode_all_variants() {
272        assert_eq!(MenuMode::Inline, MenuMode::Inline);
273        assert_eq!(MenuMode::Horizontal, MenuMode::Horizontal);
274        assert_ne!(MenuMode::Inline, MenuMode::Horizontal);
275    }
276
277    #[test]
278    fn menu_mode_clone() {
279        let original = MenuMode::Horizontal;
280        let cloned = original;
281        assert_eq!(original, cloned);
282    }
283
284    #[test]
285    fn menu_item_node_leaf() {
286        let item = MenuItemNode::leaf("item1", "Item 1");
287        assert_eq!(item.id, "item1");
288        assert_eq!(item.label, "Item 1");
289        assert!(item.icon.is_none());
290        assert_eq!(item.disabled, false);
291        assert!(item.children.is_none());
292    }
293
294    #[test]
295    fn menu_item_node_leaf_with_strings() {
296        let item = MenuItemNode::leaf(String::from("item2"), String::from("Item 2"));
297        assert_eq!(item.id, "item2");
298        assert_eq!(item.label, "Item 2");
299        assert_eq!(item.disabled, false);
300    }
301
302    #[test]
303    fn menu_item_node_clone() {
304        let item1 = MenuItemNode::leaf("item1", "Item 1");
305        let item2 = item1.clone();
306        assert!(item1 == item2);
307    }
308
309    #[test]
310    fn menu_item_node_partial_eq() {
311        let item1 = MenuItemNode::leaf("item1", "Item 1");
312        let item2 = MenuItemNode::leaf("item1", "Item 1");
313        let item3 = MenuItemNode::leaf("item2", "Item 2");
314        assert!(item1 == item2);
315        assert!(item1 != item3);
316    }
317
318    #[test]
319    fn menu_item_node_with_children() {
320        let child1 = MenuItemNode::leaf("child1", "Child 1");
321        let child2 = MenuItemNode::leaf("child2", "Child 2");
322        let parent = MenuItemNode {
323            id: "parent".to_string(),
324            label: "Parent".to_string(),
325            icon: None,
326            disabled: false,
327            children: Some(vec![child1, child2]),
328        };
329        assert_eq!(parent.id, "parent");
330        assert_eq!(parent.label, "Parent");
331        assert!(parent.children.is_some());
332        assert_eq!(parent.children.as_ref().unwrap().len(), 2);
333    }
334}