1use dioxus::prelude::*;
2
3#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5pub enum MenuMode {
6 #[default]
7 Inline,
8 Horizontal,
9}
10
11#[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#[derive(Props, Clone, PartialEq)]
35pub struct MenuProps {
36 pub items: Vec<MenuItemNode>,
38 #[props(default)]
40 pub mode: MenuMode,
41 #[props(optional)]
43 pub selected_keys: Option<Vec<String>>,
44 #[props(optional)]
46 pub default_selected_keys: Option<Vec<String>>,
47 #[props(optional)]
49 pub open_keys: Option<Vec<String>>,
50 #[props(optional)]
52 pub default_open_keys: Option<Vec<String>>,
53 #[props(optional)]
55 pub on_select: Option<EventHandler<String>>,
56 #[props(optional)]
58 pub on_open_change: Option<EventHandler<Vec<String>>>,
59 #[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#[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 let selected_internal: Signal<Vec<String>> =
87 use_signal(|| default_selected_keys.unwrap_or_else(Vec::new));
88
89 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 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 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 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 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}