biji_ui/components/menubar/
menu.rs

1use std::time::Duration;
2
3use leptos::{
4    context::Provider,
5    ev::{click, focus, keydown},
6    prelude::*,
7};
8use leptos_use::{
9    on_click_outside, use_element_bounding, use_event_listener, UseElementBoundingReturn,
10};
11use wasm_bindgen::JsCast;
12
13use crate::{
14    custom_animated_show::CustomAnimatedShow,
15    items::{Focus, ManageFocus, NavigateItems, Toggle},
16    utils::{positioning::Positioning, prevent_scroll::use_prevent_scroll},
17};
18
19use super::context::{MenuContext, RootContext};
20
21#[component]
22pub fn Menu(
23    #[prop(default = false)] disabled: bool,
24    #[prop(into, optional)] class: String,
25    #[prop(default = Positioning::BottomStart)] positioning: Positioning,
26    /// The timeout after which the component will be unmounted if `when == false`
27    #[prop(default = Duration::from_millis(200))]
28    hide_delay: Duration,
29    children: Children,
30) -> impl IntoView {
31    let ctx = expect_context::<RootContext>();
32
33    let index = ctx.next_index();
34
35    let menu_ctx = MenuContext {
36        index,
37        disabled,
38        allow_loop: ctx.allow_item_loop,
39        positioning,
40        hide_delay,
41        ..Default::default()
42    };
43
44    ctx.upsert_item(index, menu_ctx);
45
46    on_cleanup(move || {
47        ctx.remove_item(index);
48    });
49
50    let menu_ref = menu_ctx.menu_ref;
51
52    view! {
53        <Provider value={menu_ctx}>
54            <div node_ref={menu_ref} class={class} data-index={index}>
55                {children()}
56            </div>
57        </Provider>
58    }
59}
60
61#[component]
62pub fn MenuTrigger(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
63    let root_ctx = expect_context::<RootContext>();
64    let menu_ctx = expect_context::<MenuContext>();
65
66    let trigger_ref = menu_ctx.trigger_ref;
67
68    view! {
69        <MenuTriggerEvents>
70            <div
71                node_ref={trigger_ref}
72                class={class}
73                data-state={menu_ctx.index}
74                data-disabled={menu_ctx.disabled}
75                data-highlighted={move || root_ctx.item_in_focus(menu_ctx.index)}
76                data-open={move || menu_ctx.open.get()}
77                tabindex=0
78            >
79                {children()}
80            </div>
81        </MenuTriggerEvents>
82    }
83}
84
85#[component]
86pub fn MenuTriggerEvents(children: Children) -> impl IntoView {
87    let root_ctx = expect_context::<RootContext>();
88    let menu_ctx = expect_context::<MenuContext>();
89
90    let eff = RenderEffect::new(move |_| {
91        if menu_ctx.open.get() == false {
92            menu_ctx.set_focus(None);
93        }
94    });
95
96    let _ = use_event_listener(menu_ctx.trigger_ref, click, move |_| {
97        menu_ctx.toggle();
98    });
99
100    let _ = use_event_listener(menu_ctx.trigger_ref, keydown, move |evt| {
101        let key = evt.key();
102
103        if key == "ArrowRight" {
104            if let Some(item) = root_ctx.navigate_next_item() {
105                if menu_ctx.open.get() {
106                    item.open();
107                }
108                item.focus();
109                menu_ctx.close();
110            }
111        } else if key == "ArrowLeft" {
112            if let Some(item) = root_ctx.navigate_previous_item() {
113                if menu_ctx.open.get() {
114                    item.open();
115                }
116                item.focus();
117                menu_ctx.close();
118            }
119        } else if key == "ArrowDown" || key == "Enter" {
120            if !menu_ctx.open.get() {
121                menu_ctx.open();
122            }
123            if let Some(item) = menu_ctx.navigate_first_item() {
124                item.focus();
125            }
126        } else if key == "Escape" {
127            root_ctx.close_all();
128        }
129    });
130
131    let _ = use_event_listener(menu_ctx.trigger_ref, focus, move |_| {
132        root_ctx.set_focus(Some(menu_ctx.index));
133    });
134
135    let _ = on_click_outside(menu_ctx.menu_ref, move |evt| {
136        if menu_ctx.open.get() {
137            // Recursive function to check if click is within any submenu or nested submenu
138            fn is_click_in_submenu_tree(
139                menu_context: &super::context::MenuContext,
140                target: &web_sys::Element,
141            ) -> bool {
142                menu_context.items.with(|items| {
143                    items.values().any(|item| {
144                        if let super::context::ItemData::SubMenuItem { child_context, .. } = item {
145                            // Check if click is on the submenu trigger
146                            if let Some(trigger_el) = child_context.trigger_ref.get() {
147                                if trigger_el.contains(Some(target)) {
148                                    return true;
149                                }
150                            }
151                            // Check if click is within the submenu content
152                            if let Some(menu_el) = child_context.menu_ref.get() {
153                                if menu_el.contains(Some(target)) {
154                                    return true;
155                                }
156                            }
157                            // Recursively check nested submenus
158                            if is_click_in_submenu_tree(child_context, target) {
159                                return true;
160                            }
161                        }
162                        false
163                    })
164                })
165            }
166
167            // Check if the click target is within any submenu (recursively)
168            let is_submenu_click = if let Some(target) = evt.target() {
169                if let Ok(target_el) = target.dyn_into::<web_sys::Element>() {
170                    is_click_in_submenu_tree(&menu_ctx, &target_el)
171                } else {
172                    false
173                }
174            } else {
175                false
176            };
177
178            if !is_submenu_click {
179                menu_ctx.close();
180            }
181        }
182    });
183
184    let ps_eff = use_prevent_scroll(
185        move || root_ctx.prevent_scroll && menu_ctx.open.get(),
186        menu_ctx.hide_delay,
187    );
188
189    on_cleanup(move || {
190        drop(eff);
191        drop(ps_eff);
192    });
193
194    children()
195}
196
197#[component]
198pub fn MenuContent(
199    children: ChildrenFn,
200    /// Optional CSS class to apply to both show and hide classes
201    #[prop(into, optional)]
202    class: String,
203    /// Optional CSS class to apply if `when == true`
204    #[prop(into, optional)]
205    show_class: String,
206    /// Optional CSS class to apply if `when == false`
207    #[prop(into, optional)]
208    hide_class: String,
209) -> impl IntoView {
210    let menu_ctx = expect_context::<MenuContext>();
211
212    let content_ref = NodeRef::<leptos::html::Div>::new();
213
214    let UseElementBoundingReturn {
215        width: content_width,
216        height: content_height,
217        ..
218    } = use_element_bounding(content_ref);
219
220    let UseElementBoundingReturn {
221        top: trigger_top,
222        left: trigger_left,
223        width: trigger_width,
224        height: trigger_height,
225        ..
226    } = use_element_bounding(menu_ctx.trigger_ref);
227
228    let style = move || {
229        menu_ctx.positioning.calculate_position_style_simple(
230            *trigger_top.read(),
231            *trigger_left.read(),
232            *trigger_width.read(),
233            *trigger_height.read(),
234            *content_height.read(),
235            *content_width.read(),
236            0.0,
237        )
238    };
239
240    view! {
241        <CustomAnimatedShow
242            when={menu_ctx.open}
243            show_class={show_class.clone()}
244            hide_class={hide_class.clone()}
245            hide_delay={menu_ctx.hide_delay}
246        >
247            <div node_ref={content_ref} class={class.clone()} style={style}>
248                {children()}
249            </div>
250        </CustomAnimatedShow>
251    }
252}