biji_ui/components/menubar/
item.rs

1use std::time::Duration;
2
3use leptos::{
4    context::Provider,
5    ev::{focus, keydown},
6    prelude::*,
7};
8use leptos_use::{use_element_bounding, use_event_listener, UseElementBoundingReturn};
9use wasm_bindgen::JsCast;
10use web_sys::{HtmlAnchorElement, HtmlButtonElement};
11
12use crate::{
13    components::menubar::context::ItemData,
14    custom_animated_show::CustomAnimatedShow,
15    items::{Focus, GetIndex, ManageFocus, NavigateItems, Toggle},
16    utils::positioning::Positioning,
17};
18
19use super::context::{MenuContext, RootContext};
20
21#[component]
22pub fn Item(
23    #[prop(default = false)] disabled: bool,
24    #[prop(into, optional)] class: String,
25    children: Children,
26) -> impl IntoView {
27    let menu_ctx = expect_context::<MenuContext>();
28
29    let item_ctx = use_context::<ItemData>();
30
31    let trigger_ref = NodeRef::new();
32
33    let index = menu_ctx.next_index();
34
35    let item_ctx = ItemData::Item {
36        index,
37        disabled,
38        trigger_ref,
39        is_submenu: item_ctx.is_some(),
40    };
41
42    menu_ctx.upsert_item(index, item_ctx);
43
44    on_cleanup(move || {
45        menu_ctx.remove_item(index);
46    });
47
48    view! {
49        <Provider value={item_ctx}>
50            <ItemTriggerEvents>
51                <div
52                    node_ref={trigger_ref}
53                    class={class}
54                    tabindex=0
55                    data-state={item_ctx.get_index()}
56                    data-disabled={item_ctx.get_disabled()}
57                    data-highlighted={move || menu_ctx.item_in_focus(item_ctx.get_index())}
58                >
59                    {children()}
60                </div>
61            </ItemTriggerEvents>
62        </Provider>
63    }
64}
65
66#[component]
67pub fn ItemTriggerEvents(children: Children) -> impl IntoView {
68    let root_ctx = expect_context::<RootContext>();
69    let menu_ctx = expect_context::<MenuContext>();
70    let item_ctx = expect_context::<ItemData>();
71
72    let handle_on_click = move || {
73        if let Some(trigger_ref) = item_ctx.get_trigger_ref().get() {
74            if let Some(child) = trigger_ref.children().get_with_index(0) {
75                if let Ok(child) = child.clone().dyn_into::<HtmlButtonElement>() {
76                    let _ = child.click();
77                } else if let Ok(child) = child.dyn_into::<HtmlAnchorElement>() {
78                    let _ = child.click();
79                }
80            }
81        }
82    };
83
84    let _ = use_event_listener(item_ctx.get_trigger_ref(), keydown, move |evt| {
85        let key = evt.key();
86
87        match key.as_str() {
88            "ArrowDown" => {
89                evt.prevent_default();
90                if let Some(item) = menu_ctx.navigate_next_item() {
91                    item.focus();
92                }
93            }
94            "ArrowUp" => {
95                evt.prevent_default();
96                if let Some(item) = menu_ctx.navigate_previous_item() {
97                    item.focus();
98                }
99            }
100            "ArrowRight" => {
101                evt.prevent_default();
102                match item_ctx {
103                    ItemData::Item { .. } => {
104                        if let Some(item) = root_ctx.navigate_next_item() {
105                            root_ctx.close_all();
106                            item.focus();
107                            item.open();
108                        }
109                    }
110                    ItemData::SubMenuItem { child_context, .. } => {
111                        if !child_context.open.get_untracked() {
112                            child_context.open();
113                        } else {
114                            if let Some(item) = child_context.navigate_first_item() {
115                                item.focus();
116                            } else {
117                                menu_ctx.close();
118                            }
119                        }
120                    }
121                };
122            }
123            "ArrowLeft" => {
124                evt.prevent_default();
125                if item_ctx.is_submenu() {
126                    menu_ctx.close();
127                    menu_ctx.focus();
128                    menu_ctx.item_focus.set(None);
129                } else {
130                    if let Some(item) = root_ctx.navigate_previous_item() {
131                        item.focus();
132                        item.open();
133                        menu_ctx.close();
134                    }
135                }
136            }
137            "Enter" => {
138                match item_ctx {
139                    ItemData::Item { .. } => {
140                        handle_on_click();
141                        root_ctx.close_all();
142                        root_ctx.focus_active_item();
143                    }
144                    ItemData::SubMenuItem { .. } => {
145                        // Don't handle Enter for SubMenuItem here - it's handled by SubMenuItemTriggerEvents
146                        // to avoid conflicts between the two event handlers
147                    }
148                };
149            }
150            "Escape" => {
151                menu_ctx.close();
152                menu_ctx.focus();
153            }
154            _ => {}
155        };
156    });
157
158    let _ = use_event_listener(item_ctx.get_trigger_ref(), focus, move |_| {
159        menu_ctx.set_focus(Some(item_ctx.get_index()));
160        menu_ctx.close_all();
161        match item_ctx {
162            ItemData::SubMenuItem { child_context, .. } => {
163                if !child_context.open.get_untracked() {
164                    child_context.open();
165                }
166            }
167            _ => {}
168        }
169    });
170
171    children()
172}
173
174#[component]
175pub fn SubMenuItem(
176    #[prop(default = false)] disabled: bool,
177    #[prop(into, optional)] class: String,
178    #[prop(default = Positioning::RightStart)] positioning: Positioning,
179    /// The timeout after which the component will be unmounted if `when == false`
180    #[prop(default = Duration::from_millis(200))]
181    hide_delay: Duration,
182    children: Children,
183) -> impl IntoView {
184    let menu_ctx = expect_context::<MenuContext>();
185
186    let item_ctx = use_context::<ItemData>();
187
188    let index = menu_ctx.next_index();
189
190    let sub_menu_ctx = MenuContext {
191        index,
192        disabled,
193        allow_loop: menu_ctx.allow_loop,
194        positioning,
195        hide_delay,
196        ..Default::default()
197    };
198
199    let item_ctx = ItemData::SubMenuItem {
200        index,
201        disabled,
202        is_submenu: item_ctx.is_some(),
203        parent_context: menu_ctx,
204        child_context: sub_menu_ctx,
205    };
206
207    menu_ctx.upsert_item(index, item_ctx);
208
209    on_cleanup(move || {
210        menu_ctx.remove_item(index);
211    });
212
213    view! {
214        <Provider value={item_ctx}>
215            <div class={class}>
216                <ItemTriggerEvents>
217                    <Provider value={sub_menu_ctx}>{children()}</Provider>
218                </ItemTriggerEvents>
219            </div>
220        </Provider>
221    }
222}
223
224#[component]
225pub fn SubMenuItemTrigger(
226    #[prop(into, optional)] class: String,
227    children: Children,
228) -> impl IntoView {
229    let item_ctx = expect_context::<MenuContext>();
230    let sub_menu_ctx = expect_context::<ItemData>();
231
232    let trigger_ref = item_ctx.trigger_ref;
233
234    view! {
235        <SubMenuItemTriggerEvents>
236            <div
237                node_ref={trigger_ref}
238                class={class}
239                tabindex=0
240                data-state={item_ctx.index}
241                data-disabled={item_ctx.disabled}
242                data-highlighted={move || match sub_menu_ctx {
243                    ItemData::SubMenuItem { parent_context, .. } => {
244                        parent_context.item_in_focus(item_ctx.index)
245                    }
246                    _ => false,
247                }}
248            >
249
250                {children()}
251            </div>
252        </SubMenuItemTriggerEvents>
253    }
254}
255
256#[component]
257pub fn SubMenuItemTriggerEvents(children: Children) -> impl IntoView {
258    let menu_ctx = expect_context::<MenuContext>();
259    let item_ctx = expect_context::<ItemData>();
260
261    let _ = use_event_listener(menu_ctx.trigger_ref, keydown, move |evt| {
262        if evt.key() == "Enter" {
263            evt.prevent_default();
264            evt.stop_propagation();
265            menu_ctx.toggle();
266            match item_ctx {
267                ItemData::SubMenuItem { child_context, .. } => {
268                    if menu_ctx.open.get_untracked() {
269                        if let Some(item) = child_context.navigate_first_item() {
270                            item.focus();
271                        }
272                    }
273                }
274                _ => {}
275            };
276        }
277    });
278
279    children()
280}
281
282#[component]
283pub fn SubMenuItemContent(
284    children: ChildrenFn,
285    /// Optional CSS class to apply to both show and hide classes
286    #[prop(into, optional)]
287    class: String,
288    /// Optional CSS class to apply if `when == true`
289    #[prop(into, optional)]
290    show_class: String,
291    /// Optional CSS class to apply if `when == false`
292    #[prop(into, optional)]
293    hide_class: String,
294) -> impl IntoView {
295    let menu_ctx = expect_context::<MenuContext>();
296
297    let content_ref = NodeRef::<leptos::html::Div>::new();
298
299    let UseElementBoundingReturn {
300        width: content_width,
301        height: content_height,
302        ..
303    } = use_element_bounding(content_ref);
304
305    let UseElementBoundingReturn {
306        top: trigger_top,
307        left: trigger_left,
308        width: trigger_width,
309        height: trigger_height,
310        ..
311    } = use_element_bounding(menu_ctx.trigger_ref);
312
313    let style = move || {
314        menu_ctx.positioning.calculate_position_style_simple(
315            *trigger_top.read(),
316            *trigger_left.read(),
317            *trigger_width.read(),
318            *trigger_height.read(),
319            *content_height.read(),
320            *content_width.read(),
321            0.0,
322        )
323    };
324
325    view! {
326        <CustomAnimatedShow
327            when={menu_ctx.open}
328            show_class={show_class}
329            hide_class={hide_class}
330            hide_delay={menu_ctx.hide_delay}
331        >
332            <div node_ref={content_ref} class={class.clone()} style={style}>
333                {children()}
334            </div>
335        </CustomAnimatedShow>
336    }
337}