radix_leptos_menu/
menu.rs

1// TODO: remove
2#![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        // Capture phase ensures we set the boolean before any side effects execute
116        // in response to the key or pointer event as they might depend on this value.
117        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    /// Specify a container element to portal the content into.
180    #[prop(into, optional)]
181    container: MaybeProp<web_sys::Element>,
182    /// Used to force mounting when more control is needed. Useful when controlling animation with animation libraries.
183    #[prop(into, optional)]
184    force_mount: MaybeProp<bool>,
185    children: ChildrenFn,
186) -> impl IntoView {
187    // TODO: portal
188    // view! {}
189    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    // TODO: Presence
215    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    // Hide everything from ARIA except the `MenuContent`.
248    Effect::new(move |_| {
249        if let Some(content) = content_ref.get() {
250            // TODO: imported from `aria-hidden` in JS.
251            // hide_others(content);
252        }
253    });
254
255    view! {
256        <MenuContentImpl
257            // We make sure we're not trapping once it's been closed (closed != unmounted when animating out).
258            trap_focus=context.open
259            // Make sure to only disable pointer events when open. This avoids blocking interactions while animating out.
260            disable_outside_pointer_events=context.open
261            disable_outside_scroll=true
262            // When focus is trapped, a `focusout` event may still happen. We make sure we don't trigger our `on_dismiss` in such case.
263            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    /// Event handler called when auto-focusing on open. Can be prevented.
303    #[prop(into, optional)]
304    on_open_auto_focus: Option<Callback<Event>>,
305    /// Event handler called when auto-focusing on close. Can be prevented.
306    #[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    /// Whether scrolling outside the `MenuContent` should be prevented. Defaults to `false`.
318    #[prop(into, optional)]
319    disable_outside_scroll: MaybeProp<bool>,
320    /// Whether focus should be trapped within the `MenuContent`. Defaults to `false`.
321    #[prop(into, optional)]
322    trap_focus: MaybeProp<bool>,
323    #[prop(into, optional)]
324    /// Whether keyboard navigation should loop around. Defaults to `false`.
325    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            // Reset search 1 second after it was last updated.
387            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    // Make sure the whole tree has focus guards as our `MenuContent` may be the last element in the DOM (beacuse of the `Portal`).
413    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        // TODO: style
467    ]);
468
469    let attrs = StoredValue::new(attrs);
470    let children = StoredValue::new(children);
471
472    // TODO: ScrollLockWrapper, DismissableLayer
473    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                        // When opening, explicitly focus the content area only and leave `onEntryFocus` in  control of focusing first item.
482                        event.prevent_default();
483
484                        if let Some(content) = content_ref.get_untracked() {
485                            // TODO: focus with options doesn't exist in web-sys
486                            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                            // Submenu key events bubble through portals. We only care about keys in this menu.
513                            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                                // Menus should not be navigated using tab key so we prevent it.
521                                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                            // Focus first/last item based on key pressed.
530                            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                            // Clear search buffer when leaving the menu.
549                            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                            // We don't use `event.movementX` for this check because Safari will always return `0` on a pointer event.
562                            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                // Pointer down can move to a different menu item which should activate it on pointer up.
694                // We dispatch a click for selection to allow composition with click based triggers and to
695                // prevent Firefox from getting stuck in text selection mode when the menu closes.
696                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                    // We prevent default browser behaviour for selection keys as they should trigger a selection only:
712                    // - prevents space from scrolling the page.
713                    // - if keydown causes focus to move, prevents keydown from firing on the new target.
714                    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    // Get the item's `.textContent` as default strategy for typeahead `textValue`.
744    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                    /*
785                    * We focus items on `pointermove` to achieve the following:
786                    *
787                    * - Mouse over an item (it focuses)
788                    * - Leave mouse where it is and use keyboard to focus a different item
789                    * - Wiggle mouse without it leaving previously focused item
790                    * - Previously focused item should re-focus
791                    *
792                    * If we used `mouseover`/`mouseenter` it would not re-focus when the mouse
793                    * wiggles. This is to match native menu implementation.
794                    */
795                    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                                // TODO: focus options
803                                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 focus is already where we want to go, we don't want to keep going through the candidates.
915        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
926/// Wraps an array around itself at a given start index.
927fn wrap_array<T: Clone>(array: &mut [T], start_index: usize) -> &[T] {
928    array.rotate_right(start_index);
929    array
930}
931
932/// This is the "meat" of the typeahead matching logic. It takes in all the values,
933/// the search and the current match, and returns the next match (or `None`).
934///
935/// We normalize the search because if a user has repeatedly pressed a character,
936/// we want the exact same behavior as if we only had that one character
937/// (ie. cycle through options starting with that character)
938///
939/// We also reorder the values by wrapping the array around the current match.
940/// This is so we always look forward from the current match, and picking the first
941/// match will always be the correct one.
942///
943/// Finally, if the normalized search is exactly one character, we exclude the
944/// current match from the values because otherwise it would be the first to match always
945/// and focus would never move. This is as opposed to the regular case, where we
946/// don't want focus to move if the current match still matches.
947fn 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
1008/// Determine if a point is inside of a polygon.
1009fn 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}