1use dioxus::prelude::*;
2use freya_core::platform::CursorIcon;
3use freya_elements::{
4 self as dioxus_elements,
5 Code,
6 KeyboardEvent,
7};
8use freya_hooks::{
9 use_applied_theme,
10 use_focus,
11 use_platform,
12 MenuContainerTheme,
13 MenuContainerThemeWith,
14 MenuItemTheme,
15 MenuItemThemeWith,
16};
17
18#[cfg_attr(feature = "docs",
102 doc = embed_doc_image::embed_image!("menu", "images/gallery_menu.png")
103)]
104#[component]
105pub fn Menu(children: Element, onclose: Option<EventHandler<()>>) -> Element {
106 use_context_provider(|| Signal::new(ROOT_MENU.0));
108 use_context_provider::<Signal<Vec<MenuId>>>(|| Signal::new(vec![ROOT_MENU]));
110 use_context_provider(|| ROOT_MENU);
112
113 rsx!(
114 rect {
115 margin: "2 0",
116 onglobalclick: move |_| {
117 if let Some(onclose) = &onclose {
118 onclose.call(());
119 }
120 },
121 onglobalkeydown: move |ev| {
122 if ev.data.code == Code::Escape {
123 if let Some(onclose) = &onclose {
124 onclose.call(());
125 }
126 }
127 },
128 MenuContainer {
129 {children}
130 }
131 }
132 )
133}
134
135#[derive(Clone, Copy, PartialEq)]
136struct MenuId(usize);
137
138static ROOT_MENU: MenuId = MenuId(0);
139
140fn close_menus_until(menus: &mut Signal<Vec<MenuId>>, until_to: MenuId) {
141 loop {
142 let last_menu_id = menus.read().last().cloned();
143 if let Some(last_menu_id) = last_menu_id {
144 if last_menu_id != until_to {
145 menus.write().pop();
146 } else {
147 break;
148 }
149 } else {
150 break;
151 }
152 }
153}
154
155fn push_menu(menus: &mut Signal<Vec<MenuId>>, menu_id: MenuId) {
156 let last_menu_id = menus.read().last().cloned();
157 if let Some(last_menu_id) = last_menu_id {
158 if last_menu_id != menu_id {
159 menus.write().push(menu_id)
160 }
161 } else {
162 menus.write().push(menu_id)
163 }
164}
165
166#[derive(Debug, Default, PartialEq, Clone, Copy)]
168pub enum MenuItemStatus {
169 #[default]
171 Idle,
172 Hovering,
174}
175
176#[allow(non_snake_case)]
179#[component]
180pub fn MenuItem(
181 children: Element,
183 theme: Option<MenuItemThemeWith>,
185 onpress: Option<EventHandler<()>>,
187 onmouseenter: Option<EventHandler<()>>,
189) -> Element {
190 let mut focus = use_focus();
191 let mut status = use_signal(MenuItemStatus::default);
192 let platform = use_platform();
193
194 let a11y_id = focus.attribute();
195
196 let MenuItemTheme {
197 hover_background,
198 corner_radius,
199 font_theme,
200 } = use_applied_theme!(&theme, menu_item);
201
202 use_drop(move || {
203 if *status.read() == MenuItemStatus::Hovering {
204 platform.set_cursor(CursorIcon::default());
205 }
206 });
207
208 let onclick = move |_| {
209 focus.request_focus();
210 if let Some(onpress) = &onpress {
211 onpress.call(())
212 }
213 };
214
215 let onkeydown = move |ev: KeyboardEvent| {
216 if focus.validate_keydown(&ev) {
217 if let Some(onpress) = &onpress {
218 onpress.call(())
219 }
220 }
221 };
222
223 let onmouseenter = move |_| {
224 platform.set_cursor(CursorIcon::Pointer);
225 status.set(MenuItemStatus::Hovering);
226
227 if let Some(onmouseenter) = &onmouseenter {
228 onmouseenter.call(());
229 }
230 };
231
232 let onmouseleave = move |_| {
233 platform.set_cursor(CursorIcon::default());
234 status.set(MenuItemStatus::default());
235 };
236
237 let background = match *status.read() {
238 _ if focus.is_focused_with_keyboard() => &hover_background,
239 MenuItemStatus::Hovering => &hover_background,
240 MenuItemStatus::Idle => "transparent",
241 };
242
243 rsx!(
244 rect {
245 onclick,
246 onkeydown,
247 onmouseenter,
248 onmouseleave,
249 a11y_id,
250 min_width: "110",
251 width: "fill-min",
252 padding: "6 12",
253 margin: "2",
254 a11y_role: "button",
255 color: "{font_theme.color}",
256 corner_radius: "{corner_radius}",
257 background: "{background}",
258 text_align: "start",
259 main_align: "center",
260 {children}
261 }
262 )
263}
264
265#[allow(non_snake_case)]
267#[component]
268pub fn SubMenu(
269 menu: Element,
271 children: Element,
273) -> Element {
274 let parent_menu_id = use_context::<MenuId>();
275 let mut menus = use_context::<Signal<Vec<MenuId>>>();
276 let mut menus_ids_generator = use_context::<Signal<usize>>();
277 let submenu_id = use_hook(|| {
278 menus_ids_generator += 1;
279 provide_context(MenuId(*menus_ids_generator.peek()))
280 });
281
282 let show_submenu = menus.read().contains(&submenu_id);
283
284 rsx!(
285 MenuItem {
286 onmouseenter: move |_| {
287 close_menus_until(&mut menus, parent_menu_id);
288 push_menu(&mut menus, submenu_id);
289 },
290 onpress: move |_| {
291 close_menus_until(&mut menus, parent_menu_id);
292 push_menu(&mut menus, submenu_id);
293 },
294 {children}
295 if show_submenu {
296 rect {
297 position_top: "-12",
298 position_right: "-20",
299 position: "absolute",
300 width: "0",
301 height: "0",
302 rect {
303 width: "100v",
304 MenuContainer {
305 {menu}
306 }
307 }
308 }
309 }
310 }
311 )
312}
313
314#[allow(non_snake_case)]
316#[component]
317pub fn MenuButton(
318 children: Element,
320 onpress: Option<EventHandler<()>>,
322) -> Element {
323 let mut menus = use_context::<Signal<Vec<MenuId>>>();
324 let parent_menu_id = use_context::<MenuId>();
325 rsx!(
326 MenuItem {
327 onmouseenter: move |_| close_menus_until(&mut menus, parent_menu_id),
328 onpress: move |_| {
329 if let Some(onpress) = &onpress {
330 onpress.call(())
331 }
332 },
333 {children}
334 }
335 )
336}
337
338#[allow(non_snake_case)]
340#[component]
341pub fn MenuContainer(
342 children: Element,
344 theme: Option<MenuContainerThemeWith>,
346) -> Element {
347 let MenuContainerTheme {
348 background,
349 padding,
350 shadow,
351 border_fill,
352 corner_radius,
353 } = use_applied_theme!(&theme, menu_container);
354 rsx!(
355 rect {
356 background: "{background}",
357 corner_radius: "{corner_radius}",
358 shadow: "{shadow}",
359 padding: "{padding}",
360 content: "fit",
361 border: "1 inner {border_fill}",
362 {children}
363 }
364 )
365}
366
367#[cfg(test)]
368mod test {
369 use dioxus::prelude::use_signal;
370 use freya::prelude::*;
371 use freya_testing::prelude::*;
372
373 #[tokio::test]
374 pub async fn menu() {
375 fn menu_app() -> Element {
376 let mut show_menu = use_signal(|| false);
377
378 rsx!(
379 Body {
380 Button {
381 onpress: move |_| show_menu.toggle(),
382 label { "Open Menu" }
383 }
384 if *show_menu.read() {
385 Menu {
386 onclose: move |_| show_menu.set(false),
387 MenuButton {
388 label {
389 "Open"
390 }
391 }
392 MenuButton {
393 label {
394 "Save"
395 }
396 }
397 SubMenu {
398 menu: rsx!(
399 MenuButton {
400 label {
401 "Option 1"
402 }
403 }
404 SubMenu {
405 menu: rsx!(
406 MenuButton {
407 label {
408 "Option 3"
409 }
410 }
411 ),
412 label {
413 "More Options"
414 }
415 }
416 ),
417 label {
418 "Options"
419 }
420 }
421 MenuButton {
422 label {
423 "Close"
424 }
425 }
426 }
427 }
428 }
429 )
430 }
431
432 let mut utils = launch_test(menu_app);
433 utils.wait_for_update().await;
434
435 let start_size = utils.sdom().get().layout().size();
436
437 assert_eq!(utils.sdom().get().layout().size(), 5);
438
439 utils.click_cursor((15., 15.)).await;
441
442 assert_eq!(
444 utils
445 .root()
446 .get(0)
447 .get(1)
448 .get(0)
449 .get(0)
450 .get(0)
451 .get(0)
452 .text(),
453 Some("Open")
454 );
455
456 assert!(utils.sdom().get().layout().size() > start_size);
457
458 utils.click_cursor((15., 60.)).await;
460
461 assert_eq!(utils.sdom().get().layout().size(), start_size);
462
463 utils.click_cursor((15., 15.)).await;
465
466 let one_submenu_opened = utils.sdom().get().layout().size();
467 assert!(one_submenu_opened > start_size);
468
469 utils.move_cursor((15., 130.)).await;
471
472 assert_eq!(
474 utils
475 .root()
476 .get(0)
477 .get(1)
478 .get(0)
479 .get(2)
480 .get(1)
481 .get(0)
482 .get(0)
483 .get(0)
484 .get(0)
485 .get(0)
486 .text(),
487 Some("Option 1")
488 );
489
490 assert!(utils.sdom().get().layout().size() > one_submenu_opened);
491
492 utils.move_cursor((15., 90.)).await;
494
495 assert_eq!(utils.sdom().get().layout().size(), one_submenu_opened);
496
497 utils.click_cursor((333., 333.)).await;
499
500 assert_eq!(utils.sdom().get().layout().size(), start_size);
501 }
502}