biji_ui/components/menubar/
item.rs1use 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 }
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 #[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 #[prop(into, optional)]
287 class: String,
288 #[prop(into, optional)]
290 show_class: String,
291 #[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}