1#![allow(dead_code, unused_variables)]
3
4use std::rc::Rc;
5use std::{marker::PhantomData, ops::Deref};
6
7use ev::CustomEvent;
8use leptos::{
9 ev::{Event, FocusEvent, KeyboardEvent, MouseEvent, PointerEvent},
10 html::AnyElement,
11 *,
12};
13use radix_leptos_collection::{
14 use_collection, CollectionItemSlot, CollectionProvider, CollectionSlot,
15};
16use radix_leptos_compose_refs::use_composed_refs;
17use radix_leptos_direction::{use_direction, Direction};
18use radix_leptos_dismissable_layer::{
19 FocusOutsideEvent, InteractOutsideEvent, PointerDownOutsideEvent,
20};
21use radix_leptos_focus_guards::use_focus_guards;
22use radix_leptos_focus_scope::FocusScope;
23use radix_leptos_popper::{Popper, PopperAnchor, PopperArrow, PopperContent};
24use radix_leptos_primitive::{compose_callbacks, Primitive};
25use radix_leptos_roving_focus::{Orientation, RovingFocusGroup, RovingFocusGroupItem};
26use web_sys::{
27 wasm_bindgen::{closure::Closure, JsCast},
28 AddEventListenerOptions, CustomEventInit, EventListenerOptions,
29};
30
31const SELECTION_KEYS: [&str; 2] = ["Enter", " "];
32const FIRST_KEYS: [&str; 3] = ["ArrowDown", "PageUp", "Home"];
33const LAST_KEYS: [&str; 3] = ["ArrowUp", "PageDown", "End"];
34const FIRST_LAST_KEYS: [&str; 6] = ["ArrowDown", "PageUp", "Home", "ArrowUp", "PageDown", "End"];
35
36#[derive(Clone, Debug)]
37struct ItemData {
38 disabled: bool,
39 text_value: String,
40}
41
42const ITEM_DATA_PHANTHOM: PhantomData<ItemData> = PhantomData;
43
44#[derive(Clone)]
45struct MenuContextValue {
46 open: Signal<bool>,
47 content_ref: NodeRef<AnyElement>,
48 on_open_change: Callback<bool>,
49}
50
51#[derive(Clone)]
52struct MenuRootContextValue {
53 is_using_keyboard: Signal<bool>,
54 dir: Signal<Direction>,
55 modal: Signal<bool>,
56 on_close: Callback<()>,
57}
58
59#[component]
60pub fn Menu(
61 #[prop(into, optional)] open: MaybeProp<bool>,
62 #[prop(into, optional)] dir: MaybeProp<Direction>,
63 #[prop(into, optional)] modal: MaybeProp<bool>,
64 #[prop(into, optional)] on_open_change: Option<Callback<bool>>,
65 children: ChildrenFn,
66) -> impl IntoView {
67 let children = StoredValue::new(children);
68
69 let open = Signal::derive(move || open.get().unwrap_or(false));
70 let modal = Signal::derive(move || modal.get().unwrap_or(true));
71 let on_open_change = on_open_change.unwrap_or(Callback::new(|_| {}));
72
73 let content_ref: NodeRef<AnyElement> = NodeRef::new();
74 let is_using_keyboard = RwSignal::new(false);
75 let direction = use_direction(dir);
76
77 let context_value = StoredValue::new(MenuContextValue {
78 open,
79 content_ref,
80 on_open_change,
81 });
82 let root_context_value = StoredValue::new(MenuRootContextValue {
83 is_using_keyboard: is_using_keyboard.into(),
84 dir: direction,
85 modal,
86 on_close: Callback::new(move |_| on_open_change.call(false)),
87 });
88
89 let handle_pointer: Rc<Closure<dyn Fn(PointerEvent)>> = Rc::new(Closure::new(move |_| {
90 is_using_keyboard.set(false);
91 }));
92 let cleanup_handle_pointer = handle_pointer.clone();
93
94 let handle_key_down: Rc<Closure<dyn Fn(KeyboardEvent)>> = Rc::new(Closure::new(move |_| {
95 is_using_keyboard.set(true);
96
97 document()
98 .add_event_listener_with_callback_and_add_event_listener_options(
99 "pointerdown",
100 (*handle_pointer).as_ref().unchecked_ref(),
101 AddEventListenerOptions::new().capture(true).once(true),
102 )
103 .expect("Pointer down event listener should be added.");
104 document()
105 .add_event_listener_with_callback_and_add_event_listener_options(
106 "pointermove",
107 (*handle_pointer).as_ref().unchecked_ref(),
108 AddEventListenerOptions::new().capture(true).once(true),
109 )
110 .expect("Pointer move event listener should be added.");
111 }));
112 let cleanup_handle_key_down = handle_key_down.clone();
113
114 Effect::new(move |_| {
115 document()
118 .add_event_listener_with_callback_and_add_event_listener_options(
119 "keydown",
120 (*handle_key_down).as_ref().unchecked_ref(),
121 AddEventListenerOptions::new().capture(true),
122 )
123 .expect("Key down event listener should be added.");
124 });
125
126 on_cleanup(move || {
127 document()
128 .remove_event_listener_with_callback_and_event_listener_options(
129 "keydown",
130 (*cleanup_handle_key_down).as_ref().unchecked_ref(),
131 EventListenerOptions::new().capture(true),
132 )
133 .expect("Key down event listener should be removed.");
134
135 document()
136 .remove_event_listener_with_callback_and_event_listener_options(
137 "pointerdown",
138 (*cleanup_handle_pointer).as_ref().unchecked_ref(),
139 EventListenerOptions::new().capture(true),
140 )
141 .expect("Pointer down event listener should be removed.");
142
143 document()
144 .remove_event_listener_with_callback_and_event_listener_options(
145 "pointermove",
146 (*cleanup_handle_pointer).as_ref().unchecked_ref(),
147 EventListenerOptions::new().capture(true),
148 )
149 .expect("Pointer move event listener should be removed.");
150 });
151
152 view! {
153 <Popper>
154 <Provider value=context_value.get_value()>
155 <Provider value=root_context_value.get_value()>
156 {children.with_value(|children| children())}
157 </Provider>
158 </Provider>
159 </Popper>
160 }
161}
162
163#[component]
164pub fn MenuAnchor(
165 #[prop(into, optional)] as_child: MaybeProp<bool>,
166 #[prop(optional)] node_ref: NodeRef<AnyElement>,
167 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
168 children: ChildrenFn,
169) -> impl IntoView {
170 view! {
171 <PopperAnchor as_child=as_child node_ref=node_ref attrs=attrs>
172 {children()}
173 </PopperAnchor>
174 }
175}
176
177#[component]
178pub fn MenuPortal(
179 #[prop(into, optional)]
181 container: MaybeProp<web_sys::Element>,
182 #[prop(into, optional)]
184 force_mount: MaybeProp<bool>,
185 children: ChildrenFn,
186) -> impl IntoView {
187 children()
190}
191
192#[derive(Clone)]
193struct MenuContentContextValue {
194 on_item_enter: Callback<PointerEvent>,
195 on_item_leave: Callback<PointerEvent>,
196 on_trigger_leave: Callback<PointerEvent>,
197 search: RwSignal<String>,
198 pointer_grace_timer: RwSignal<u64>,
199 on_pointer_grace_intent_change: Callback<Option<GraceIntent>>,
200}
201
202#[component]
203pub fn MenuContent(
204 #[prop(into, optional)] as_child: MaybeProp<bool>,
205 #[prop(optional)] node_ref: NodeRef<AnyElement>,
206 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
207 children: ChildrenFn,
208) -> impl IntoView {
209 let attrs: StoredValue<Vec<(&str, Attribute)>> = StoredValue::new(attrs);
210 let children = StoredValue::new(children);
211
212 let root_context = expect_context::<MenuRootContextValue>();
213
214 view! {
216 <CollectionProvider item_data_type=ITEM_DATA_PHANTHOM>
217 <CollectionSlot item_data_type=ITEM_DATA_PHANTHOM>
218 <Show
219 when=move || root_context.modal.get()
220 fallback=move || view!{
221 <MenuRootContentNonModal attrs=attrs.get_value()>
222 {children.with_value(|children| children())}
223 </MenuRootContentNonModal>
224 }
225 >
226 <MenuRootContentModal as_child=as_child node_ref=node_ref attrs=attrs.get_value()>
227 {children.with_value(|children| children())}
228 </MenuRootContentModal>
229 </Show>
230 </CollectionSlot>
231 </CollectionProvider>
232 }
233}
234
235#[component]
236fn MenuRootContentModal(
237 #[prop(into, optional)] on_focus_outside: Option<Callback<FocusOutsideEvent>>,
238 #[prop(into, optional)] as_child: MaybeProp<bool>,
239 #[prop(optional)] node_ref: NodeRef<AnyElement>,
240 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
241 children: ChildrenFn,
242) -> impl IntoView {
243 let context = expect_context::<MenuContextValue>();
244 let content_ref: NodeRef<AnyElement> = NodeRef::new();
245 let composed_refs = use_composed_refs(vec![node_ref, content_ref]);
246
247 Effect::new(move |_| {
249 if let Some(content) = content_ref.get() {
250 }
253 });
254
255 view! {
256 <MenuContentImpl
257 trap_focus=context.open
259 disable_outside_pointer_events=context.open
261 disable_outside_scroll=true
262 on_focus_outside=compose_callbacks(on_focus_outside, Some(Callback::new(move |event: FocusOutsideEvent| {
264 event.prevent_default();
265 })), Some(false))
266 on_dismiss=move |_| context.on_open_change.call(false)
267 as_child=as_child
268 node_ref=composed_refs
269 attrs=attrs
270 >
271 {children()}
272 </MenuContentImpl>
273 }
274}
275
276#[component]
277fn MenuRootContentNonModal(
278 #[prop(into, optional)] as_child: MaybeProp<bool>,
279 #[prop(optional)] node_ref: NodeRef<AnyElement>,
280 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
281 children: ChildrenFn,
282) -> impl IntoView {
283 let context = expect_context::<MenuContextValue>();
284
285 view! {
286 <MenuContentImpl
287 trap_focus=false
288 disable_outside_pointer_events=false
289 disable_outside_scroll=false
290 on_dismiss=move |_| context.on_open_change.call(false)
291 as_child=as_child
292 node_ref=node_ref
293 attrs=attrs
294 >
295 {children()}
296 </MenuContentImpl>
297 }
298}
299
300#[component]
301fn MenuContentImpl(
302 #[prop(into, optional)]
304 on_open_auto_focus: Option<Callback<Event>>,
305 #[prop(into, optional)]
307 on_close_auto_focus: Option<Callback<Event>>,
308 #[prop(into, optional)] disable_outside_pointer_events: MaybeProp<bool>,
309 #[prop(into, optional)] on_escape_key_down: Option<Callback<KeyboardEvent>>,
310 #[prop(into, optional)] on_pointer_down_outside: Option<Callback<PointerDownOutsideEvent>>,
311 #[prop(into, optional)] on_focus_outside: Option<Callback<FocusOutsideEvent>>,
312 #[prop(into, optional)] on_interact_outside: Option<Callback<InteractOutsideEvent>>,
313 #[prop(into, optional)] on_dismiss: Option<Callback<()>>,
314 #[prop(into, optional)] on_key_down: Option<Callback<KeyboardEvent>>,
315 #[prop(into, optional)] on_blur: Option<Callback<FocusEvent>>,
316 #[prop(into, optional)] on_pointer_move: Option<Callback<PointerEvent>>,
317 #[prop(into, optional)]
319 disable_outside_scroll: MaybeProp<bool>,
320 #[prop(into, optional)]
322 trap_focus: MaybeProp<bool>,
323 #[prop(into, optional)]
324 r#loop: MaybeProp<bool>,
326 #[prop(into, optional)] on_entry_focus: Option<Callback<Event>>,
327 #[prop(into, optional)] as_child: MaybeProp<bool>,
328 #[prop(optional)] node_ref: NodeRef<AnyElement>,
329 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
330 children: ChildrenFn,
331) -> impl IntoView {
332 let r#loop = Signal::derive(move || r#loop.get().unwrap_or(false));
333
334 let context = expect_context::<MenuContextValue>();
335 let root_context = expect_context::<MenuRootContextValue>();
336 let get_items = StoredValue::new(use_collection::<ItemData>());
337 let (current_item_id, set_current_item_id) = create_signal::<Option<String>>(None);
338 let content_ref: NodeRef<AnyElement> = NodeRef::new();
339 let composed_refs = use_composed_refs(vec![node_ref, content_ref]);
340 let timer = RwSignal::new(0);
341 let search = RwSignal::new("".to_string());
342 let pointer_grace_timer = RwSignal::new(0);
343 let pointer_grace_intent: RwSignal<Option<GraceIntent>> = RwSignal::new(None);
344 let pointer_dir = RwSignal::new(Side::Right);
345 let last_pointer_x = RwSignal::new(0);
346
347 let clear_search: Closure<dyn Fn()> = Closure::new(move || {
348 search.set("".into());
349 window().clear_timeout_with_handle(timer.get());
350 });
351
352 let handle_typeahead_search = Callback::new(move |key: String| {
353 let search_value = search.get() + &key;
354 let items = get_items.with_value(|get_items| get_items());
355 let items = items
356 .iter()
357 .filter(|item| !item.data.disabled)
358 .collect::<Vec<_>>();
359 let current_item = document().active_element();
360 let current_match = items
361 .iter()
362 .find(|item| {
363 item.r#ref.get().map(|html_element| {
364 let element: &web_sys::Element = html_element.deref();
365 element.clone()
366 }) == current_item
367 })
368 .map(|item| item.data.text_value.clone());
369 let values = items
370 .iter()
371 .map(|item| item.data.text_value.clone())
372 .collect::<Vec<_>>();
373 let next_match = get_next_match(values, search_value.clone(), current_match);
374 let new_item = items
375 .iter()
376 .find(|item| {
377 next_match
378 .as_ref()
379 .is_some_and(|next_match| item.data.text_value == *next_match)
380 })
381 .and_then(|item| item.r#ref.get());
382
383 search.set(search_value.clone());
384 window().clear_timeout_with_handle(timer.get());
385 if !search_value.is_empty() {
386 timer.set(
388 window()
389 .set_timeout_with_callback_and_timeout_and_arguments_0(
390 clear_search.as_ref().unchecked_ref(),
391 1000,
392 )
393 .expect("Timeout should be set"),
394 );
395 }
396
397 if let Some(new_item) = new_item {
398 window()
399 .set_timeout_with_callback(
400 Closure::once(move || new_item.deref().focus())
401 .as_ref()
402 .unchecked_ref(),
403 )
404 .expect("Timeout should be set.");
405 }
406 });
407
408 on_cleanup(move || {
409 window().clear_timeout_with_handle(timer.get());
410 });
411
412 use_focus_guards();
414
415 let is_pointer_moving_to_submenu = move |event: &PointerEvent| -> bool {
416 let is_moving_towards = Some(pointer_dir.get())
417 == pointer_grace_intent
418 .get()
419 .map(|pointer_grace_intent| pointer_grace_intent.side);
420 is_moving_towards
421 && is_pointer_in_grace_area(
422 event,
423 pointer_grace_intent
424 .get()
425 .map(|pointer_grace_intent| pointer_grace_intent.area),
426 )
427 };
428
429 let content_context_value = StoredValue::new(MenuContentContextValue {
430 search,
431 on_item_enter: Callback::new(move |event| {
432 if is_pointer_moving_to_submenu(&event) {
433 event.prevent_default();
434 }
435 }),
436 on_item_leave: Callback::new(move |event| {
437 if is_pointer_moving_to_submenu(&event) {
438 return;
439 }
440 if let Some(content) = content_ref.get() {
441 content.focus().expect("Element should be focused.");
442 }
443 set_current_item_id.set(None);
444 }),
445 on_trigger_leave: Callback::new(move |event| {
446 if is_pointer_moving_to_submenu(&event) {
447 event.prevent_default();
448 }
449 }),
450 pointer_grace_timer,
451 on_pointer_grace_intent_change: Callback::new(move |intent| {
452 pointer_grace_intent.set(intent);
453 }),
454 });
455
456 let mut attrs = attrs.clone();
457 attrs.extend([
458 ("role", "menu".into_attribute()),
459 ("aria-orientation", "vertical".into_attribute()),
460 (
461 "data-state",
462 (move || get_open_state(context.open.get())).into_attribute(),
463 ),
464 ("data-radix-menu-content", "".into_attribute()),
465 ("dir", (move || root_context.dir.get()).into_attribute()),
466 ]);
468
469 let attrs = StoredValue::new(attrs);
470 let children = StoredValue::new(children);
471
472 view! {
474 <Provider value=content_context_value.get_value()>
475 <FocusScope
476 as_child=true
477 trapped=trap_focus
478 on_mount_auto_focus=compose_callbacks(
479 on_open_auto_focus,
480 Some(Callback::new(move |event: Event| {
481 event.prevent_default();
483
484 if let Some(content) = content_ref.get_untracked() {
485 content.focus().expect("Element should be focused");
487 }
488 })),
489 None,
490 )
491 on_unmount_auto_focus=on_close_auto_focus
492 >
493 <RovingFocusGroup
494 as_child=true
495 dir=root_context.dir
496 orientation=Orientation::Vertical
497 r#loop=r#loop
498 current_tab_stop_id=current_item_id
499 on_current_tab_stop_id_change=move |value| set_current_item_id.set(value)
500 on_entry_focus=compose_callbacks(on_entry_focus, Some(Callback::new(move |event: Event| {
501 if !root_context.is_using_keyboard.get() {
502 event.prevent_default();
503 }
504 })), None)
505 prevent_scroll_on_entry_focus=true
506 >
507 <PopperContent
508 as_child=as_child
509 node_ref=composed_refs
510 attrs=attrs.get_value()
511 on:keydown=compose_callbacks(on_key_down, Some(Callback::new(move |event: KeyboardEvent| {
512 let target = event.target().map(|target| target.unchecked_into::<web_sys::HtmlElement>()).expect("Event should have target.");
514 let is_key_down_inside = target.closest("[data-radix-menu-content]").expect("Element should be able to query closest.") ==
515 event.current_target().and_then(|current_target| current_target.dyn_into::<web_sys::Element>().ok());
516 let is_modifier_key = event.ctrl_key() || event.alt_key() || event.meta_key();
517 let is_character_key = event.key().len() == 1;
518
519 if is_key_down_inside {
520 if event.key() == "Tab" {
522 event.prevent_default();
523 }
524 if !is_modifier_key && is_character_key {
525 handle_typeahead_search.call(event.key());
526 }
527 }
528
529 if content_ref.get().is_some_and(|content| *content == target) {
531 if !FIRST_LAST_KEYS.contains(&event.key().as_str()) {
532 return;
533 }
534
535 event.prevent_default();
536
537 let items = get_items.with_value(|get_items| get_items());
538 let items = items.iter().filter(|item| !item.data.disabled);
539 let mut candidate_nodes: Vec<web_sys::HtmlElement> = items.map(|item| item.r#ref.get().expect("Item ref should have element.").deref().clone()).collect();
540 if LAST_KEYS.contains(&event.key().as_str()) {
541 candidate_nodes.reverse();
542 }
543 focus_first(candidate_nodes);
544 }
545
546 })), None)
547 on:blur=compose_callbacks(on_blur, Some(Callback::new(move |event: FocusEvent| {
548 let target = event.target().map(|target| target.unchecked_into::<web_sys::Node>()).expect("Event should have target.");
550 let current_target = event.current_target().map(|current_target| current_target.unchecked_into::<web_sys::Node>()).expect("Event should have current target.");
551 if !current_target.contains(Some(&target)) {
552 window().clear_timeout_with_handle(timer.get());
553 search.set("".into());
554 }
555 })), None)
556 on:pointermove=compose_callbacks(on_pointer_move, Some(when_mouse(move |event: PointerEvent| {
557 let target = event.target().map(|target| target.unchecked_into::<web_sys::HtmlElement>()).expect("Event should have target.");
558 let current_target = event.current_target().map(|current_target| current_target.unchecked_into::<web_sys::Node>()).expect("Event should have current target.");
559 let pointer_x_has_changed = last_pointer_x.get() != event.client_x();
560
561 if current_target.contains(Some(&target)) && pointer_x_has_changed {
563 let new_dir = match event.client_x() > last_pointer_x.get() {
564 true => Side::Right,
565 false => Side::Left
566 };
567 pointer_dir.set(new_dir);
568 last_pointer_x.set(event.client_x());
569 }
570 })), None)
571 >
572 {children.with_value(|children| children())}
573 </PopperContent>
574 </RovingFocusGroup>
575 </FocusScope>
576 </Provider>
577 }
578}
579
580#[component]
581pub fn MenuGroup(
582 #[prop(into, optional)] as_child: MaybeProp<bool>,
583 #[prop(optional)] node_ref: NodeRef<AnyElement>,
584 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
585 children: ChildrenFn,
586) -> impl IntoView {
587 let mut attrs = attrs.clone();
588 attrs.extend([("role", "group".into_attribute())]);
589
590 view! {
591 <Primitive
592 element=html::div
593 as_child=as_child
594 node_ref=node_ref
595 attrs=attrs
596 >
597 {children()}
598 </Primitive>
599 }
600}
601
602#[component]
603pub fn MenuLabel(
604 #[prop(into, optional)] as_child: MaybeProp<bool>,
605 #[prop(optional)] node_ref: NodeRef<AnyElement>,
606 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
607 children: ChildrenFn,
608) -> impl IntoView {
609 view! {
610 <Primitive
611 element=html::div
612 as_child=as_child
613 node_ref=node_ref
614 attrs=attrs
615 >
616 {children()}
617 </Primitive>
618 }
619}
620
621const ITEM_SELECT: &str = "menu.itemSelect";
622
623#[component]
624pub fn MenuItem(
625 #[prop(into, optional)] disabled: MaybeProp<bool>,
626 #[prop(into, optional)] on_select: Option<Callback<Event>>,
627 #[prop(into, optional)] on_click: Option<Callback<MouseEvent>>,
628 #[prop(into, optional)] on_pointer_down: Option<Callback<PointerEvent>>,
629 #[prop(into, optional)] on_pointer_up: Option<Callback<PointerEvent>>,
630 #[prop(into, optional)] on_key_down: Option<Callback<KeyboardEvent>>,
631 #[prop(into, optional)] as_child: MaybeProp<bool>,
632 #[prop(optional)] node_ref: NodeRef<AnyElement>,
633 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
634 children: ChildrenFn,
635) -> impl IntoView {
636 let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
637
638 let item_ref: NodeRef<AnyElement> = NodeRef::new();
639 let composed_refs = use_composed_refs(vec![node_ref, item_ref]);
640 let root_context = expect_context::<MenuRootContextValue>();
641 let content_context = expect_context::<MenuContentContextValue>();
642 let is_pointer_down = RwSignal::new(false);
643
644 let handle_select = Callback::new(move |_: MouseEvent| {
645 if disabled.get() {
646 return;
647 }
648
649 if let Some(item) = item_ref.get() {
650 let closure: Closure<dyn Fn(Event)> = Closure::new(move |event: Event| {
651 if let Some(on_select) = on_select {
652 on_select.call(event);
653 }
654 });
655
656 let item_select_event = CustomEvent::new_with_event_init_dict(
657 ITEM_SELECT,
658 CustomEventInit::new().bubbles(true).cancelable(true),
659 )
660 .expect("Item select event should be instantiated.");
661
662 item.add_event_listener_with_callback_and_add_event_listener_options(
663 ITEM_SELECT,
664 closure.as_ref().unchecked_ref(),
665 AddEventListenerOptions::new().once(true),
666 )
667 .expect("Item select event listener should be added.");
668 item.dispatch_event(&item_select_event)
669 .expect("Item select event should be dispatched.");
670
671 if item_select_event.default_prevented() {
672 is_pointer_down.set(false);
673 } else {
674 root_context.on_close.call(());
675 }
676 }
677 });
678
679 view! {
680 <MenuItemImpl
681 disabled={disabled}
682 as_child=as_child
683 node_ref=composed_refs
684 attrs=attrs
685 on:click=compose_callbacks(on_click, Some(handle_select), None)
686 on:pointerdown=move |event| {
687 if let Some(on_pointer_down) = on_pointer_down {
688 on_pointer_down.call(event);
689 }
690 is_pointer_down.set(true);
691 }
692 on:pointerup=compose_callbacks(on_pointer_up, Some(Callback::new(move |event: PointerEvent| {
693 if is_pointer_down.get() {
697 if let Some(current_target) = event.current_target().map(|current_target| current_target.unchecked_into::<web_sys::HtmlElement>()) {
698 current_target.click();
699 }
700 }
701 })), None)
702 on:keydown=compose_callbacks(on_key_down, Some(Callback::new(move |event: KeyboardEvent| {
703 let is_typing_ahead = !content_context.search.get().is_empty();
704 if disabled.get() || (is_typing_ahead && event.key() == " ") {
705 return;
706 }
707 if SELECTION_KEYS.contains(&event.key().as_str()) {
708 let current_target = event.current_target().map(|current_target| current_target.unchecked_into::<web_sys::HtmlElement>()).expect("Event should have current target.");
709 current_target.click();
710
711 event.prevent_default();
715 }
716 })), None)
717 >
718 {children()}
719 </MenuItemImpl>
720 }
721}
722
723#[component]
724fn MenuItemImpl(
725 #[prop(into, optional)] disabled: MaybeProp<bool>,
726 #[prop(into, optional)] text_value: MaybeProp<String>,
727 #[prop(into, optional)] on_pointer_move: Option<Callback<PointerEvent>>,
728 #[prop(into, optional)] on_pointer_leave: Option<Callback<PointerEvent>>,
729 #[prop(into, optional)] on_focus: Option<Callback<FocusEvent>>,
730 #[prop(into, optional)] on_blur: Option<Callback<FocusEvent>>,
731 #[prop(into, optional)] as_child: MaybeProp<bool>,
732 #[prop(optional)] node_ref: NodeRef<AnyElement>,
733 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
734 children: ChildrenFn,
735) -> impl IntoView {
736 let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
737
738 let content_context = expect_context::<MenuContentContextValue>();
739 let item_ref: NodeRef<AnyElement> = NodeRef::new();
740 let composed_ref = use_composed_refs(vec![node_ref, item_ref]);
741 let (is_focused, set_is_focused) = create_signal(false);
742
743 let (text_content, set_text_content) = create_signal("".to_string());
745 Effect::new(move |_| {
746 if let Some(item) = item_ref.get() {
747 set_text_content.set(item.text_content().unwrap_or("".into()).trim().into());
748 }
749 });
750
751 let item_data = Signal::derive(move || ItemData {
752 disabled: disabled.get(),
753 text_value: text_value.get().unwrap_or(text_content.get()),
754 });
755
756 let mut attrs = attrs.clone();
757 attrs.extend([
758 ("role", "menuitem".into_attribute()),
759 (
760 "data-highlighted",
761 (move || is_focused.get().then_some("")).into_attribute(),
762 ),
763 (
764 "aria-disabled",
765 (move || disabled.get().then_some("true")).into_attribute(),
766 ),
767 (
768 "data-disabled",
769 (move || disabled.get().then_some("")).into_attribute(),
770 ),
771 ]);
772
773 let attrs = StoredValue::new(attrs);
774 let children = StoredValue::new(children);
775
776 view! {
777 <CollectionItemSlot item_data_type=ITEM_DATA_PHANTHOM item_data=item_data>
778 <RovingFocusGroupItem as_child=true focusable=Signal::derive(move || !disabled.get())>
779 <Primitive
780 element=html::div
781 as_child=as_child
782 node_ref=composed_ref
783 attrs=attrs.get_value()
784 on:pointermove=compose_callbacks(on_pointer_move, Some(when_mouse(move |event| {
796 if disabled.get() {
797 content_context.on_item_leave.call(event);
798 } else {
799 content_context.on_item_enter.call(event.clone());
800 if !event.default_prevented() {
801 let item = event.current_target().map(|target| target.unchecked_into::<web_sys::HtmlElement>()).expect("Current target should exist.");
802 item.focus().expect("Element should be focused.");
804 }
805 }
806 })), None)
807 on:pointerleave=compose_callbacks(on_pointer_leave, Some(when_mouse(move |event| {
808 content_context.on_item_leave.call(event);
809 })), None)
810 on:focus=compose_callbacks(on_focus, Some(Callback::new(move |_| {
811 set_is_focused.set(true);
812 })), None)
813 on:blur=compose_callbacks(on_focus, Some(Callback::new(move |_| {
814 set_is_focused.set(false);
815 })), None)
816 >
817 {children.with_value(|children| children())}
818 </Primitive>
819 </RovingFocusGroupItem>
820 </CollectionItemSlot>
821 }
822}
823
824#[component]
825pub fn MenuCheckboxItem() -> impl IntoView {
826 view! {}
827}
828
829#[component]
830pub fn MenuRadioGroup() -> impl IntoView {
831 view! {}
832}
833
834#[component]
835pub fn MenuRadioItem() -> impl IntoView {
836 view! {}
837}
838
839#[component]
840pub fn MenuItemIndicator() -> impl IntoView {
841 view! {}
842}
843
844#[component]
845pub fn MenuSeparator(
846 #[prop(into, optional)] as_child: MaybeProp<bool>,
847 #[prop(optional)] node_ref: NodeRef<AnyElement>,
848 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
849 #[prop(optional)] children: Option<ChildrenFn>,
850) -> impl IntoView {
851 let children = StoredValue::new(children);
852
853 let mut attrs = attrs.clone();
854 attrs.extend([
855 ("role", "separator".into_attribute()),
856 ("aria-orientation", "horizontal".into_attribute()),
857 ]);
858
859 view! {
860 <Primitive
861 element=html::div
862 as_child=as_child
863 node_ref=node_ref
864 attrs=attrs
865 >
866 {children.with_value(|children| children.as_ref().map(|children| children()))}
867 </Primitive>
868 }
869}
870
871#[component]
872pub fn MenuArrow(
873 #[prop(into, optional)] as_child: MaybeProp<bool>,
874 #[prop(optional)] node_ref: NodeRef<AnyElement>,
875 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
876 children: ChildrenFn,
877) -> impl IntoView {
878 view! {
879 <PopperArrow
880 as_child=as_child
881 node_ref=node_ref
882 attrs=attrs
883 >
884 {children()}
885 </PopperArrow>
886 }
887}
888
889#[component]
890pub fn MenuSub() -> impl IntoView {
891 view! {}
892}
893
894#[component]
895pub fn MenuSubTrigger() -> impl IntoView {
896 view! {}
897}
898
899#[component]
900pub fn MenuSubContent() -> impl IntoView {
901 view! {}
902}
903
904fn get_open_state(open: bool) -> String {
905 match open {
906 true => "open".into(),
907 false => "closed".into(),
908 }
909}
910
911fn focus_first(candidates: Vec<web_sys::HtmlElement>) {
912 let previously_focused_element = document().active_element();
913 for candidate in candidates {
914 if previously_focused_element.as_ref() == candidate.dyn_ref::<web_sys::Element>() {
916 return;
917 }
918
919 candidate.focus().expect("Element should be focused.");
920 if document().active_element() != previously_focused_element {
921 return;
922 }
923 }
924}
925
926fn wrap_array<T: Clone>(array: &mut [T], start_index: usize) -> &[T] {
928 array.rotate_right(start_index);
929 array
930}
931
932fn get_next_match(
948 values: Vec<String>,
949 search: String,
950 current_match: Option<String>,
951) -> Option<String> {
952 let is_repeated =
953 search.chars().count() > 1 && search.chars().all(|c| c == search.chars().next().unwrap());
954 let normilized_search = match is_repeated {
955 true => search.chars().take(1).collect(),
956 false => search,
957 };
958 let current_match_index = match current_match.as_ref() {
959 Some(current_match) => values.iter().position(|value| value == current_match),
960 None => None,
961 };
962 let mut wrapped_values =
963 wrap_array(&mut values.clone(), current_match_index.unwrap_or(0)).to_vec();
964 let exclude_current_match = normilized_search.chars().count() == 1;
965 if exclude_current_match {
966 wrapped_values = wrapped_values
967 .into_iter()
968 .filter(|v| {
969 current_match.is_none()
970 || current_match
971 .as_ref()
972 .is_some_and(|current_match| v != current_match)
973 })
974 .collect::<Vec<_>>();
975 }
976 let next_match = wrapped_values.into_iter().find(|value| {
977 value
978 .to_lowercase()
979 .starts_with(&normilized_search.to_lowercase())
980 });
981
982 match next_match != current_match {
983 true => next_match,
984 false => None,
985 }
986}
987
988#[derive(Clone, Debug)]
989struct Point {
990 x: f64,
991 y: f64,
992}
993
994type Polygon = Vec<Point>;
995
996#[derive(Clone, Debug, PartialEq)]
997enum Side {
998 Left,
999 Right,
1000}
1001
1002#[derive(Clone, Debug)]
1003struct GraceIntent {
1004 area: Polygon,
1005 side: Side,
1006}
1007
1008fn is_point_in_polygon(point: Point, polygon: Polygon) -> bool {
1010 let Point { x, y } = point;
1011 let mut inside = false;
1012
1013 let mut i = 0;
1014 let mut j = polygon.len() - 1;
1015 while i < polygon.len() {
1016 let xi = polygon[i].x;
1017 let yi = polygon[i].y;
1018 let xj = polygon[j].x;
1019 let yj = polygon[j].y;
1020
1021 let intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
1022 if intersect {
1023 inside = !inside;
1024 }
1025
1026 j = i;
1027 i += 1;
1028 }
1029
1030 inside
1031}
1032
1033fn is_pointer_in_grace_area(event: &PointerEvent, area: Option<Polygon>) -> bool {
1034 if let Some(area) = area {
1035 let cursor_pos = Point {
1036 x: event.client_x() as f64,
1037 y: event.client_y() as f64,
1038 };
1039 is_point_in_polygon(cursor_pos, area)
1040 } else {
1041 false
1042 }
1043}
1044
1045fn when_mouse<H: Fn(PointerEvent) + 'static>(handler: H) -> Callback<PointerEvent> {
1046 Callback::new(move |event: PointerEvent| {
1047 if event.pointer_type() == "mouse" {
1048 handler(event);
1049 }
1050 })
1051}