biji_ui/components/menubar/
menu.rs1use 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 #[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 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 if let Some(trigger_el) = child_context.trigger_ref.get() {
147 if trigger_el.contains(Some(target)) {
148 return true;
149 }
150 }
151 if let Some(menu_el) = child_context.menu_ref.get() {
153 if menu_el.contains(Some(target)) {
154 return true;
155 }
156 }
157 if is_click_in_submenu_tree(child_context, target) {
159 return true;
160 }
161 }
162 false
163 })
164 })
165 }
166
167 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 #[prop(into, optional)]
202 class: String,
203 #[prop(into, optional)]
205 show_class: String,
206 #[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}