freya_components/
menu.rs

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/// Floating menu, use alongside [`MenuItem`].
19///
20/// # Example
21///
22/// ```rust
23/// # use freya::prelude::*;
24/// fn app() -> Element {
25///    let mut show_menu = use_signal(|| false);
26///
27///    rsx!(
28///        Body {
29///            Button {
30///                onpress: move |_| show_menu.toggle(),
31///                label { "Open Menu" }
32///            },
33///            if *show_menu.read() {
34///                Menu {
35///                    onclose: move |_| show_menu.set(false),
36///                    MenuButton {
37///                        label {
38///                            "Open"
39///                        }
40///                    }
41///                    MenuButton {
42///                        label {
43///                            "Save"
44///                        }
45///                    }
46///                    SubMenu {
47///                        menu: rsx!(
48///                            MenuButton {
49///                                label {
50///                                    "Some option"
51///                                }
52///                            }
53///                        ),
54///                        label {
55///                            "Options"
56///                        }
57///                    }
58///                    MenuButton {
59///                        label {
60///                            "Close"
61///                        }
62///                    }
63///                }
64///            }
65///        }
66///    )
67/// }
68/// # use freya_testing::prelude::*;
69/// # launch_doc_with_utils(|| {
70/// #   rsx!(
71/// #      Menu {
72/// #         onclose: move |_| {},
73/// #         MenuButton {
74/// #             label {
75/// #                 "Open"
76/// #             }
77/// #         }
78/// #         SubMenu {
79/// #             menu: rsx!(
80/// #                 MenuButton {
81/// #                     label {
82/// #                         "Whatever"
83/// #                     }
84/// #                 }
85/// #             ),
86/// #             label {
87/// #                 "Options"
88/// #             }
89/// #         }
90/// #     }
91/// #  )
92/// # }, (250., 250.).into(), |mut utils| async move {
93/// #   utils.wait_for_update().await;
94/// #   utils.move_cursor((15., 60.)).await;
95/// #   utils.save_snapshot("./images/gallery_menu.png");
96/// # });
97/// ```
98///
99/// # Preview
100/// ![Menu Preview][menu]
101#[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    // Provide the menus ID generator
107    use_context_provider(|| Signal::new(ROOT_MENU.0));
108    // Provide the menus stack
109    use_context_provider::<Signal<Vec<MenuId>>>(|| Signal::new(vec![ROOT_MENU]));
110    // Provide this the ROOT Menu ID
111    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/// Indicates the current status of the MenuItem.
167#[derive(Debug, Default, PartialEq, Clone, Copy)]
168pub enum MenuItemStatus {
169    /// Default state.
170    #[default]
171    Idle,
172    /// Mouse is hovering the MenuItem.
173    Hovering,
174}
175
176/// # Styling
177/// Inherits the [`MenuItemTheme`](freya_hooks::MenuItemTheme) theme.
178#[allow(non_snake_case)]
179#[component]
180pub fn MenuItem(
181    /// Inner children for the MenuItem.
182    children: Element,
183    /// Theme override for the MenuItem.
184    theme: Option<MenuItemThemeWith>,
185    /// Handler for the `onpress` event.
186    onpress: Option<EventHandler<()>>,
187    /// Handler for the `onmouseenter` event.
188    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/// Create sub menus inside a [`Menu`].
266#[allow(non_snake_case)]
267#[component]
268pub fn SubMenu(
269    /// Submenu configuration.
270    menu: Element,
271    /// Inner children for the MenuButton
272    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/// Like a button, but for [`Menu`]s.
315#[allow(non_snake_case)]
316#[component]
317pub fn MenuButton(
318    /// Inner children for the MenuButton
319    children: Element,
320    /// Handler for the `onpress` event.
321    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/// Wraps the body of a [`Menu`].
339#[allow(non_snake_case)]
340#[component]
341pub fn MenuContainer(
342    /// Inner children for the MenuContainer. Usually just `MenuButton` or `SubMenu`.
343    children: Element,
344    /// Theme override.
345    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        // Open the Menu
440        utils.click_cursor((15., 15.)).await;
441
442        // Check the `Open` button exists
443        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        // Close the Menu
459        utils.click_cursor((15., 60.)).await;
460
461        assert_eq!(utils.sdom().get().layout().size(), start_size);
462
463        // Open the Menu again
464        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        // Open the SubMenu
470        utils.move_cursor((15., 130.)).await;
471
472        // Check the `Option 1` button exists
473        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        // Stop showing the submenu
493        utils.move_cursor((15., 90.)).await;
494
495        assert_eq!(utils.sdom().get().layout().size(), one_submenu_opened);
496
497        // Click somewhere also so all the menus hide
498        utils.click_cursor((333., 333.)).await;
499
500        assert_eq!(utils.sdom().get().layout().size(), start_size);
501    }
502}