Skip to main content

leptix_context_menu/
context_menu.rs

1use leptix_core::compose_refs::use_composed_refs;
2use leptix_core::dismissable_layer::use_dismissable_layer;
3use leptix_core::focus_scope::use_focus_scope;
4use leptix_core::id::use_id;
5use leptix_core::portal::Portal;
6use leptix_core::presence::use_presence;
7use leptix_core::primitive::Primitive;
8use leptos::{context::Provider, ev::KeyboardEvent, html, prelude::*};
9use leptos_node_ref::AnyNodeRef;
10use send_wrapper::SendWrapper;
11use web_sys::wasm_bindgen::JsCast;
12
13#[derive(Clone, Debug)]
14struct ContextMenuContextValue {
15    open: RwSignal<bool>,
16    content_id: String,
17    position_x: RwSignal<f64>,
18    position_y: RwSignal<f64>,
19}
20
21#[component]
22pub fn ContextMenu(
23    #[prop(into, optional)] on_open_change: Option<Callback<bool>>,
24    children: TypedChildrenFn<impl IntoView + 'static>,
25) -> impl IntoView {
26    let children = StoredValue::new(children.into_inner());
27    let open = RwSignal::new(false);
28    let base_id = use_id(None).get();
29
30    Effect::new(move |_| {
31        if let Some(cb) = on_open_change {
32            cb.run(open.get());
33        }
34    });
35
36    let ctx = ContextMenuContextValue {
37        open,
38        content_id: format!("{}-ctx", base_id),
39        position_x: RwSignal::new(0.0),
40        position_y: RwSignal::new(0.0),
41    };
42
43    view! {
44        <Provider value=ctx>
45            {children.with_value(|c| c())}
46        </Provider>
47    }
48}
49
50#[component]
51pub fn ContextMenuTrigger(
52    #[prop(into, optional)] disabled: MaybeProp<bool>,
53    #[prop(into, optional)] as_child: MaybeProp<bool>,
54    #[prop(into, optional)] node_ref: AnyNodeRef,
55    children: TypedChildrenFn<impl IntoView + 'static>,
56) -> impl IntoView {
57    let children = StoredValue::new(children.into_inner());
58    let ctx = expect_context::<ContextMenuContextValue>();
59    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
60
61    view! {
62        <Primitive
63            element=html::span
64            as_child=as_child
65            node_ref=node_ref
66            attr:data-state=move || if ctx.open.get() { "open" } else { "closed" }
67            attr:data-disabled=move || disabled.get().then_some("")
68            on:contextmenu=move |event: web_sys::MouseEvent| {
69                if !disabled.get() {
70                    event.prevent_default();
71                    // Use page coordinates (includes scroll offset) with position:absolute
72                    // for correct positioning regardless of scroll position.
73                    ctx.position_x.set(event.page_x() as f64);
74                    ctx.position_y.set(event.page_y() as f64);
75                    ctx.open.set(true);
76                }
77            }
78        >
79            {children.with_value(|c| c())}
80        </Primitive>
81    }
82}
83
84#[component]
85pub fn ContextMenuPortal(
86    #[prop(into, optional)] container: MaybeProp<SendWrapper<web_sys::Element>>,
87    #[prop(into, optional)] container_ref: AnyNodeRef,
88    #[prop(into, optional)] _force_mount: MaybeProp<bool>,
89    children: TypedChildrenFn<impl IntoView + 'static>,
90) -> impl IntoView {
91    let children = StoredValue::new(children.into_inner());
92    let ctx = expect_context::<ContextMenuContextValue>();
93    let ctx_for_portal = StoredValue::new(ctx.clone());
94    view! {
95        <Show when=move || ctx.open.get()>
96            <Portal container=container container_ref=container_ref>
97                <Provider value=ctx_for_portal.get_value()>
98                    {children.with_value(|c| c())}
99                </Provider>
100            </Portal>
101        </Show>
102    }
103}
104
105#[component]
106pub fn ContextMenuContent(
107    #[prop(into, optional)] as_child: MaybeProp<bool>,
108    #[prop(into, optional)] node_ref: AnyNodeRef,
109    children: TypedChildrenFn<impl IntoView + 'static>,
110) -> impl IntoView {
111    let children = StoredValue::new(children.into_inner());
112    let ctx = expect_context::<ContextMenuContextValue>();
113    let open = Signal::derive(move || ctx.open.get());
114    let presence = use_presence(open);
115
116    let focus_ref = use_focus_scope(Signal::derive(|| true), Signal::derive(|| true), None, None);
117    let dismiss_ref = use_dismissable_layer(
118        None,
119        None,
120        None,
121        None,
122        Some(Callback::new(move |()| ctx.open.set(false))),
123        Signal::derive(move || !ctx.open.get()),
124    );
125    let refs = use_composed_refs(vec![node_ref, presence.node_ref, focus_ref, dismiss_ref]);
126    let search_buffer: RwSignal<String> = RwSignal::new(String::new());
127    let search_timer: RwSignal<Option<i32>> = RwSignal::new(None);
128
129    view! {
130        <Show when=move || presence.is_present.get()>
131            <Primitive
132                element=html::div
133                as_child=as_child
134                node_ref=refs
135                attr:id=ctx.content_id.clone()
136                attr:role="menu"
137                attr:data-state=move || if ctx.open.get() { "open" } else { "closed" }
138                attr:tabindex="-1"
139                attr:style=move || format!("position:absolute;left:{}px;top:{}px;z-index:50;", ctx.position_x.get(), ctx.position_y.get())
140                on:keydown=move |event: KeyboardEvent| {
141                    match event.key().as_str() {
142                        "Tab" => { event.prevent_default(); }
143                        "ArrowDown" | "PageDown" => { event.prevent_default(); focus_menu_item(&event, true); }
144                        "ArrowUp" | "PageUp" => { event.prevent_default(); focus_menu_item(&event, false); }
145                        "Home" => { event.prevent_default(); focus_menu_item_edge(&event, true); }
146                        "End" => { event.prevent_default(); focus_menu_item_edge(&event, false); }
147                        key if key.len() == 1 && !event.ctrl_key() && !event.meta_key() => {
148                            handle_typeahead(&event, key, search_buffer, search_timer);
149                        }
150                        _ => {}
151                    }
152                }
153            >
154                {children.with_value(|c| c())}
155            </Primitive>
156        </Show>
157    }
158}
159
160// Simple item components that use ContextMenuContextValue directly
161// (cannot re-export from dropdown-menu because it expects MenuContextValue)
162
163#[component]
164pub fn ContextMenuItem(
165    #[prop(into, optional)] disabled: MaybeProp<bool>,
166    #[prop(into, optional)] on_select: Option<Callback<()>>,
167    #[prop(into, optional)] as_child: MaybeProp<bool>,
168    #[prop(into, optional)] node_ref: AnyNodeRef,
169    children: TypedChildrenFn<impl IntoView + 'static>,
170) -> impl IntoView {
171    let children = StoredValue::new(children.into_inner());
172    let ctx = expect_context::<ContextMenuContextValue>();
173    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
174
175    view! {
176        <Primitive element=html::div as_child=as_child node_ref=node_ref
177            attr:role="menuitem"
178            attr:data-disabled=move || disabled.get().then_some("")
179            attr:tabindex="-1"
180            on:click=move |_| {
181                if !disabled.get() {
182                    if let Some(cb) = on_select { cb.run(()); }
183                    ctx.open.set(false);
184                }
185            }
186            on:keydown=move |event: KeyboardEvent| {
187                if matches!(event.key().as_str(), "Enter" | " ") && !disabled.get() {
188                    event.prevent_default();
189                    if let Some(cb) = on_select { cb.run(()); }
190                    ctx.open.set(false);
191                }
192            }
193        >
194            {children.with_value(|c| c())}
195        </Primitive>
196    }
197}
198
199#[component]
200pub fn ContextMenuSeparator(
201    #[prop(into, optional)] as_child: MaybeProp<bool>,
202    #[prop(into, optional)] node_ref: AnyNodeRef,
203) -> impl IntoView {
204    view! {
205        <Primitive element=html::div as_child=as_child node_ref=node_ref
206            attr:role="separator"
207            attr:aria-orientation="horizontal"
208        >
209            {""}
210        </Primitive>
211    }
212}
213
214#[component]
215pub fn ContextMenuLabel(
216    #[prop(into, optional)] as_child: MaybeProp<bool>,
217    #[prop(into, optional)] node_ref: AnyNodeRef,
218    children: TypedChildrenFn<impl IntoView + 'static>,
219) -> impl IntoView {
220    let children = StoredValue::new(children.into_inner());
221    view! {
222        <Primitive element=html::div as_child=as_child node_ref=node_ref>
223            {children.with_value(|c| c())}
224        </Primitive>
225    }
226}
227
228#[component]
229pub fn ContextMenuGroup(
230    #[prop(into, optional)] as_child: MaybeProp<bool>,
231    #[prop(into, optional)] node_ref: AnyNodeRef,
232    children: TypedChildrenFn<impl IntoView + 'static>,
233) -> impl IntoView {
234    let children = StoredValue::new(children.into_inner());
235    view! {
236        <Primitive element=html::div as_child=as_child node_ref=node_ref attr:role="group">
237            {children.with_value(|c| c())}
238        </Primitive>
239    }
240}
241
242// ---------------------------------------------------------------------------
243// CheckboxItem
244// ---------------------------------------------------------------------------
245
246#[derive(Clone, Debug)]
247struct ContextMenuItemCheckedContextValue {
248    checked: Signal<bool>,
249}
250
251#[component]
252pub fn ContextMenuCheckboxItem(
253    #[prop(into, optional)] checked: MaybeProp<bool>,
254    #[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
255    #[prop(into, optional)] disabled: MaybeProp<bool>,
256    #[prop(into, optional)] on_select: Option<Callback<()>>,
257    #[prop(into, optional)] as_child: MaybeProp<bool>,
258    #[prop(into, optional)] node_ref: AnyNodeRef,
259    children: TypedChildrenFn<impl IntoView + 'static>,
260) -> impl IntoView {
261    let children = StoredValue::new(children.into_inner());
262    let ctx = expect_context::<ContextMenuContextValue>();
263    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
264    let checked = Signal::derive(move || checked.get().unwrap_or(false));
265
266    let item_checked_ctx = ContextMenuItemCheckedContextValue { checked };
267
268    view! {
269        <Provider value=item_checked_ctx>
270            <Primitive element=html::div as_child=as_child node_ref=node_ref
271                attr:role="menuitemcheckbox"
272                attr:aria-checked=move || checked.get().to_string()
273                attr:data-state=move || if checked.get() { "checked" } else { "unchecked" }
274                attr:data-disabled=move || disabled.get().then_some("")
275                attr:tabindex="-1"
276                on:click=move |_| {
277                    if !disabled.get() {
278                        if let Some(cb) = on_checked_change { cb.run(!checked.get()); }
279                        if let Some(cb) = on_select { cb.run(()); }
280                        ctx.open.set(false);
281                    }
282                }
283                on:keydown=move |event: KeyboardEvent| {
284                    if matches!(event.key().as_str(), "Enter" | " ") && !disabled.get() {
285                        event.prevent_default();
286                        if let Some(cb) = on_checked_change { cb.run(!checked.get()); }
287                        if let Some(cb) = on_select { cb.run(()); }
288                        ctx.open.set(false);
289                    }
290                }
291            >
292                {children.with_value(|c| c())}
293            </Primitive>
294        </Provider>
295    }
296}
297
298// ---------------------------------------------------------------------------
299// RadioGroup + RadioItem
300// ---------------------------------------------------------------------------
301
302#[derive(Clone, Debug)]
303struct ContextMenuRadioGroupContextValue {
304    value: Signal<Option<String>>,
305    on_value_change: Callback<String>,
306}
307
308#[component]
309pub fn ContextMenuRadioGroup(
310    #[prop(into, optional)] value: MaybeProp<String>,
311    #[prop(into, optional)] on_value_change: Option<Callback<String>>,
312    #[prop(into, optional)] as_child: MaybeProp<bool>,
313    #[prop(into, optional)] node_ref: AnyNodeRef,
314    children: TypedChildrenFn<impl IntoView + 'static>,
315) -> impl IntoView {
316    let children = StoredValue::new(children.into_inner());
317    let value = Signal::derive(move || value.get());
318    let radio_ctx = ContextMenuRadioGroupContextValue {
319        value,
320        on_value_change: Callback::new(move |v: String| {
321            if let Some(cb) = on_value_change {
322                cb.run(v);
323            }
324        }),
325    };
326
327    view! {
328        <Provider value=radio_ctx>
329            <Primitive element=html::div as_child=as_child node_ref=node_ref attr:role="group">
330                {children.with_value(|c| c())}
331            </Primitive>
332        </Provider>
333    }
334}
335
336#[component]
337pub fn ContextMenuRadioItem(
338    #[prop(into)] value: String,
339    #[prop(into, optional)] disabled: MaybeProp<bool>,
340    #[prop(into, optional)] on_select: Option<Callback<()>>,
341    #[prop(into, optional)] as_child: MaybeProp<bool>,
342    #[prop(into, optional)] node_ref: AnyNodeRef,
343    children: TypedChildrenFn<impl IntoView + 'static>,
344) -> impl IntoView {
345    let children = StoredValue::new(children.into_inner());
346    let ctx = expect_context::<ContextMenuContextValue>();
347    let radio_ctx = expect_context::<ContextMenuRadioGroupContextValue>();
348    let item_value = value.clone();
349    let item_value_click = value.clone();
350    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
351    let checked =
352        Signal::derive(move || radio_ctx.value.get().as_deref() == Some(item_value.as_str()));
353    let item_checked_ctx = ContextMenuItemCheckedContextValue { checked };
354
355    view! {
356        <Provider value=item_checked_ctx>
357            <Primitive element=html::div as_child=as_child node_ref=node_ref
358                attr:role="menuitemradio"
359                attr:aria-checked=move || checked.get().to_string()
360                attr:data-state=move || if checked.get() { "checked" } else { "unchecked" }
361                attr:data-disabled=move || disabled.get().then_some("")
362                attr:tabindex="-1"
363                on:click=move |_| {
364                    if !disabled.get() {
365                        radio_ctx.on_value_change.run(item_value_click.clone());
366                        if let Some(cb) = on_select { cb.run(()); }
367                        ctx.open.set(false);
368                    }
369                }
370                on:keydown=move |event: KeyboardEvent| {
371                    if matches!(event.key().as_str(), "Enter" | " ") && !disabled.get() {
372                        event.prevent_default();
373                        radio_ctx.on_value_change.run(value.clone());
374                        if let Some(cb) = on_select { cb.run(()); }
375                        ctx.open.set(false);
376                    }
377                }
378            >
379                {children.with_value(|c| c())}
380            </Primitive>
381        </Provider>
382    }
383}
384
385// ---------------------------------------------------------------------------
386// ItemIndicator
387// ---------------------------------------------------------------------------
388
389#[component]
390pub fn ContextMenuItemIndicator(
391    #[prop(into, optional)] force_mount: MaybeProp<bool>,
392    #[prop(into, optional)] as_child: MaybeProp<bool>,
393    #[prop(into, optional)] node_ref: AnyNodeRef,
394    #[prop(optional)] children: Option<ChildrenFn>,
395) -> impl IntoView {
396    let children = StoredValue::new(children);
397    let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
398    let checked_ctx = expect_context::<ContextMenuItemCheckedContextValue>();
399
400    view! {
401        <Show when=move || force_mount.get() || checked_ctx.checked.get()>
402            <Primitive element=html::span as_child=as_child node_ref=node_ref
403                attr:data-state=move || if checked_ctx.checked.get() { "checked" } else { "unchecked" }
404            >
405                {children.with_value(|c| c.as_ref().map(|c| c()))}
406            </Primitive>
407        </Show>
408    }
409}
410
411// ---------------------------------------------------------------------------
412// Sub / SubTrigger / SubContent
413// ---------------------------------------------------------------------------
414
415#[derive(Clone, Debug)]
416struct ContextMenuSubContextValue {
417    open: RwSignal<bool>,
418    content_id: String,
419    trigger_ref: AnyNodeRef,
420}
421
422#[component]
423pub fn ContextMenuSub(
424    #[prop(into, optional)] open: MaybeProp<bool>,
425    #[prop(into, optional)] default_open: MaybeProp<bool>,
426    #[prop(into, optional)] on_open_change: Option<Callback<bool>>,
427    children: TypedChildrenFn<impl IntoView + 'static>,
428) -> impl IntoView {
429    let children = StoredValue::new(children.into_inner());
430    let open_state = RwSignal::new(open.get().or(default_open.get()).unwrap_or(false));
431
432    Effect::new(move |_| {
433        if let Some(o) = open.get() {
434            open_state.set(o);
435        }
436    });
437    Effect::new(move |_| {
438        if let Some(cb) = on_open_change {
439            cb.run(open_state.get());
440        }
441    });
442
443    let base_id = use_id(None).get();
444    let sub_ctx = ContextMenuSubContextValue {
445        open: open_state,
446        content_id: format!("{}-sub", base_id),
447        trigger_ref: AnyNodeRef::new(),
448    };
449
450    view! {
451        <Provider value=sub_ctx>
452            {children.with_value(|c| c())}
453        </Provider>
454    }
455}
456
457#[component]
458pub fn ContextMenuSubTrigger(
459    #[prop(into, optional)] disabled: MaybeProp<bool>,
460    #[prop(into, optional)] as_child: MaybeProp<bool>,
461    #[prop(into, optional)] node_ref: AnyNodeRef,
462    children: TypedChildrenFn<impl IntoView + 'static>,
463) -> impl IntoView {
464    let children = StoredValue::new(children.into_inner());
465    let sub_ctx = expect_context::<ContextMenuSubContextValue>();
466    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
467    let refs = use_composed_refs(vec![node_ref, sub_ctx.trigger_ref]);
468
469    view! {
470        <Primitive element=html::div as_child=as_child node_ref=refs
471            attr:role="menuitem"
472            attr:aria-haspopup="menu"
473            attr:aria-expanded=move || sub_ctx.open.get().to_string()
474            attr:aria-controls=move || sub_ctx.open.get().then(|| sub_ctx.content_id.clone())
475            attr:data-state=move || if sub_ctx.open.get() { "open" } else { "closed" }
476            attr:data-disabled=move || disabled.get().then_some("")
477            attr:tabindex="-1"
478            on:click=move |_| {
479                if !disabled.get() { sub_ctx.open.set(!sub_ctx.open.get()); }
480            }
481            on:pointerenter=move |_| {
482                if !disabled.get() { sub_ctx.open.set(true); }
483            }
484            on:keydown=move |event: KeyboardEvent| {
485                if event.key() == "ArrowRight" && !disabled.get() {
486                    event.prevent_default();
487                    sub_ctx.open.set(true);
488                }
489            }
490        >
491            {children.with_value(|c| c())}
492        </Primitive>
493    }
494}
495
496#[component]
497pub fn ContextMenuSubContent(
498    #[prop(into, optional)] force_mount: MaybeProp<bool>,
499    #[prop(into, optional)] as_child: MaybeProp<bool>,
500    #[prop(into, optional)] node_ref: AnyNodeRef,
501    children: TypedChildrenFn<impl IntoView + 'static>,
502) -> impl IntoView {
503    let children = StoredValue::new(children.into_inner());
504    let sub_ctx = expect_context::<ContextMenuSubContextValue>();
505    let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
506    let present = Signal::derive(move || force_mount.get() || sub_ctx.open.get());
507    let presence = use_presence(present);
508
509    let dismiss_ref = use_dismissable_layer(
510        None,
511        None,
512        None,
513        None,
514        Some(Callback::new(move |()| sub_ctx.open.set(false))),
515        Signal::derive(move || !sub_ctx.open.get()),
516    );
517    let refs = use_composed_refs(vec![node_ref, presence.node_ref, dismiss_ref]);
518
519    view! {
520        <Show when=move || presence.is_present.get()>
521            <Primitive element=html::div as_child=as_child node_ref=refs
522                attr:id=sub_ctx.content_id.clone()
523                attr:role="menu"
524                attr:aria-orientation="vertical"
525                attr:data-state=move || if sub_ctx.open.get() { "open" } else { "closed" }
526                attr:tabindex="-1"
527                on:keydown=move |event: KeyboardEvent| {
528                    match event.key().as_str() {
529                        "ArrowDown" => { event.prevent_default(); focus_menu_item(&event, true); }
530                        "ArrowUp" => { event.prevent_default(); focus_menu_item(&event, false); }
531                        "ArrowLeft" => { event.prevent_default(); sub_ctx.open.set(false); }
532                        "Escape" => { sub_ctx.open.set(false); }
533                        _ => {}
534                    }
535                }
536                on:pointerleave=move |_| { sub_ctx.open.set(false); }
537            >
538                {children.with_value(|c| c())}
539            </Primitive>
540        </Show>
541    }
542}
543
544// ---------------------------------------------------------------------------
545// Arrow
546// ---------------------------------------------------------------------------
547
548#[component]
549pub fn ContextMenuArrow(
550    #[prop(into, optional)] width: MaybeProp<f64>,
551    #[prop(into, optional)] height: MaybeProp<f64>,
552    #[prop(into, optional)] as_child: MaybeProp<bool>,
553    #[prop(into, optional)] node_ref: AnyNodeRef,
554    #[prop(optional)] children: Option<ChildrenFn>,
555) -> impl IntoView {
556    let children = StoredValue::new(children);
557    // Context menus use fixed positioning, not Popper, so Arrow is a no-op placeholder.
558    // It renders a span for API compatibility.
559    let _width = width;
560    let _height = height;
561    view! {
562        <Primitive element=html::span as_child=as_child node_ref=node_ref>
563            {children.with_value(|c| c.as_ref().map(|c| c()))}
564        </Primitive>
565    }
566}
567
568// ---------------------------------------------------------------------------
569// Helpers
570// ---------------------------------------------------------------------------
571
572fn focus_menu_item(event: &KeyboardEvent, forward: bool) {
573    let Some(container) = event.current_target().and_then(|t| {
574        use web_sys::wasm_bindgen::JsCast;
575        t.dyn_into::<web_sys::Element>().ok()
576    }) else {
577        return;
578    };
579    let Ok(items) = container.query_selector_all(
580        "[role='menuitem']:not([data-disabled]), [role='menuitemcheckbox']:not([data-disabled]), [role='menuitemradio']:not([data-disabled])"
581    ) else {
582        return;
583    };
584    let mut nodes = vec![];
585    for i in 0..items.length() {
586        if let Some(n) = items.item(i) {
587            nodes.push(n);
588        }
589    }
590    let active = web_sys::window()
591        .and_then(|w| w.document())
592        .and_then(|d| d.active_element());
593    let idx = active.as_ref().and_then(|a| {
594        nodes
595            .iter()
596            .position(|n| n == <web_sys::Element as AsRef<web_sys::Node>>::as_ref(a))
597    });
598    let next = if forward {
599        idx.map(|i| i + 1).filter(|i| *i < nodes.len()).or(Some(0))
600    } else {
601        idx.and_then(|i| i.checked_sub(1))
602            .or(Some(nodes.len().saturating_sub(1)))
603    };
604    if let Some(i) = next
605        && let Some(n) = nodes.get(i)
606    {
607        use web_sys::wasm_bindgen::JsCast;
608        if let Ok(el) = n.clone().dyn_into::<web_sys::HtmlElement>() {
609            let _ = el.focus();
610        }
611    }
612}
613
614fn focus_menu_item_edge(event: &KeyboardEvent, first: bool) {
615    let Some(container) = event.current_target().and_then(|t| {
616        use web_sys::wasm_bindgen::JsCast;
617        t.dyn_into::<web_sys::Element>().ok()
618    }) else {
619        return;
620    };
621    let Ok(items) = container.query_selector_all(MENU_ITEM_SELECTOR) else {
622        return;
623    };
624    let target_idx = if first {
625        0
626    } else {
627        items.length().saturating_sub(1)
628    };
629    if let Some(node) = items.item(target_idx) {
630        use web_sys::wasm_bindgen::JsCast;
631        if let Ok(el) = node.dyn_into::<web_sys::HtmlElement>() {
632            let _ = el.focus();
633        }
634    }
635}
636
637fn handle_typeahead(
638    event: &KeyboardEvent,
639    key: &str,
640    search_buffer: RwSignal<String>,
641    search_timer: RwSignal<Option<i32>>,
642) {
643    let Some(container) = event.current_target().and_then(|t| {
644        use web_sys::wasm_bindgen::JsCast;
645        t.dyn_into::<web_sys::Element>().ok()
646    }) else {
647        return;
648    };
649    if let Some(id) = search_timer.get_untracked()
650        && let Some(w) = web_sys::window()
651    {
652        w.clear_timeout_with_handle(id);
653    }
654    search_buffer.update(|buf| buf.push_str(key));
655    let id = web_sys::window().and_then(|w| {
656        w.set_timeout_with_callback_and_timeout_and_arguments_0(
657            web_sys::wasm_bindgen::closure::Closure::<dyn Fn()>::new(move || {
658                search_buffer.set(String::new());
659            })
660            .into_js_value()
661            .unchecked_ref(),
662            1000,
663        )
664        .ok()
665    });
666    search_timer.set(id);
667
668    let search = search_buffer.get_untracked().to_lowercase();
669    let Ok(items) = container.query_selector_all(MENU_ITEM_SELECTOR) else {
670        return;
671    };
672    for i in 0..items.length() {
673        if let Some(node) = items.item(i) {
674            use web_sys::wasm_bindgen::JsCast;
675            if let Some(text) = node.text_content()
676                && text.trim().to_lowercase().starts_with(&search)
677                && let Ok(el) = node.dyn_into::<web_sys::HtmlElement>()
678            {
679                let _ = el.focus();
680                return;
681            }
682        }
683    }
684}
685
686const MENU_ITEM_SELECTOR: &str = "[role='menuitem']:not([data-disabled]), [role='menuitemcheckbox']:not([data-disabled]), [role='menuitemradio']:not([data-disabled])";