Skip to main content

leptix_dropdown_menu/
dropdown_menu.rs

1use floating_ui_leptos::Padding;
2use leptix_core::compose_refs::use_composed_refs;
3use leptix_core::dismissable_layer::use_dismissable_layer;
4use leptix_core::focus_scope::use_focus_scope;
5use leptix_core::id::use_id;
6use leptix_core::popper::{
7    Popper, PopperAnchor, PopperArrow, PopperContent, parse_align, parse_side,
8};
9use leptix_core::portal::Portal;
10use leptix_core::presence::use_presence;
11use leptix_core::primitive::Primitive;
12use leptos::{context::Provider, ev::KeyboardEvent, html, prelude::*};
13use leptos_node_ref::AnyNodeRef;
14use send_wrapper::SendWrapper;
15use web_sys::wasm_bindgen::JsCast;
16
17// ---------------------------------------------------------------------------
18// Context
19// ---------------------------------------------------------------------------
20
21#[derive(Clone, Debug)]
22struct MenuContextValue {
23    open: Signal<bool>,
24    on_open_change: Callback<bool>,
25    trigger_ref: AnyNodeRef,
26    content_id: String,
27    dir: Signal<leptix_core::direction::Direction>,
28}
29
30#[derive(Clone, Debug)]
31struct MenuRadioGroupContextValue {
32    value: Signal<Option<String>>,
33    on_value_change: Callback<String>,
34}
35
36#[derive(Clone, Debug)]
37struct MenuItemCheckedContextValue {
38    checked: Signal<bool>,
39}
40
41#[allow(dead_code)]
42#[derive(Clone, Debug)]
43struct SubMenuContextValue {
44    open: RwSignal<bool>,
45    content_id: String,
46    trigger_ref: AnyNodeRef,
47}
48
49// ---------------------------------------------------------------------------
50// Root
51// ---------------------------------------------------------------------------
52
53#[component]
54pub fn DropdownMenu(
55    #[prop(into, optional)] open: MaybeProp<bool>,
56    #[prop(into, optional)] default_open: MaybeProp<bool>,
57    #[prop(into, optional)] on_open_change: Option<Callback<bool>>,
58    #[prop(into, optional)] dir: MaybeProp<leptix_core::direction::Direction>,
59    children: TypedChildrenFn<impl IntoView + 'static>,
60) -> impl IntoView {
61    let children = StoredValue::new(children.into_inner());
62    let dir = Signal::derive(move || dir.get().unwrap_or(leptix_core::direction::Direction::Ltr));
63
64    let (open_state, set_open) = leptix_core::use_controllable_state::use_controllable_state(
65        leptix_core::use_controllable_state::UseControllableStateParams {
66            prop: open,
67            on_change: on_open_change.map(|cb| {
68                Callback::new(move |v: Option<bool>| {
69                    if let Some(v) = v {
70                        cb.run(v);
71                    }
72                })
73            }),
74            default_prop: default_open,
75        },
76    );
77    let open = Signal::derive(move || open_state.get().unwrap_or(false));
78    let base_id = use_id(None).get();
79
80    let context = MenuContextValue {
81        open,
82        on_open_change: Callback::new(move |v: bool| set_open.run(Some(v))),
83        trigger_ref: AnyNodeRef::new(),
84        content_id: format!("{}-content", base_id),
85        dir,
86    };
87
88    let context = StoredValue::new(context);
89    view! {
90        <Popper>
91            <Provider value=context.get_value()>
92                {children.with_value(|c| c())}
93            </Provider>
94        </Popper>
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Trigger
100// ---------------------------------------------------------------------------
101
102#[component]
103pub fn DropdownMenuTrigger(
104    #[prop(into, optional)] as_child: MaybeProp<bool>,
105    #[prop(into, optional)] node_ref: AnyNodeRef,
106    children: TypedChildrenFn<impl IntoView + 'static>,
107) -> impl IntoView {
108    let children = StoredValue::new(children.into_inner());
109    let ctx = expect_context::<MenuContextValue>();
110    let refs = use_composed_refs(vec![node_ref, ctx.trigger_ref]);
111    let content_id = StoredValue::new(ctx.content_id.clone());
112
113    view! {
114        <PopperAnchor as_child=true>
115            <Primitive element=html::button as_child=as_child node_ref=refs
116                attr:r#type="button"
117                attr:aria-haspopup="menu"
118                attr:aria-expanded=move || ctx.open.get().to_string()
119                attr:aria-controls=move || ctx.open.get().then(|| content_id.get_value())
120                attr:data-state=move || if ctx.open.get() { "open" } else { "closed" }
121                on:click=move |_| ctx.on_open_change.run(!ctx.open.get())
122                on:keydown=move |event: KeyboardEvent| {
123                    if matches!(event.key().as_str(), "ArrowDown" | "Enter" | " ") {
124                        event.prevent_default();
125                        ctx.on_open_change.run(true);
126                    }
127                }
128            >
129                {children.with_value(|c| c())}
130            </Primitive>
131        </PopperAnchor>
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Portal
137// ---------------------------------------------------------------------------
138
139#[component]
140pub fn DropdownMenuPortal(
141    #[prop(into, optional)] container: MaybeProp<SendWrapper<web_sys::Element>>,
142    #[prop(into, optional)] container_ref: AnyNodeRef,
143    #[prop(into, optional)] _force_mount: MaybeProp<bool>,
144    children: TypedChildrenFn<impl IntoView + 'static>,
145) -> impl IntoView {
146    let children = StoredValue::new(children.into_inner());
147    let ctx = expect_context::<MenuContextValue>();
148    let ctx_for_portal = StoredValue::new(ctx.clone());
149    view! {
150        <Show when=move || ctx.open.get()>
151            <Portal container=container container_ref=container_ref>
152                <Provider value=ctx_for_portal.get_value()>
153                    {children.with_value(|c| c())}
154                </Provider>
155            </Portal>
156        </Show>
157    }
158}
159
160// ---------------------------------------------------------------------------
161// Content
162// ---------------------------------------------------------------------------
163
164#[component]
165pub fn DropdownMenuContent(
166    #[prop(into, optional)] on_escape_key_down: Option<Callback<web_sys::KeyboardEvent>>,
167    #[prop(into, optional)] on_pointer_down_outside: Option<Callback<web_sys::PointerEvent>>,
168    #[prop(into, optional)] r#loop: MaybeProp<bool>,
169    /// Which side to position on: "top" | "right" | "bottom" | "left"
170    #[prop(into, optional)]
171    side: MaybeProp<String>,
172    /// Offset from the trigger (pixels).
173    #[prop(into, optional)]
174    side_offset: MaybeProp<f64>,
175    /// Alignment along the side: "start" | "center" | "end"
176    #[prop(into, optional)]
177    align: MaybeProp<String>,
178    /// Offset along the alignment axis (pixels).
179    #[prop(into, optional)]
180    align_offset: MaybeProp<f64>,
181    /// Whether to flip/shift to avoid viewport collisions.
182    #[prop(into, optional)]
183    avoid_collisions: MaybeProp<bool>,
184    /// Padding from viewport edge when avoiding collisions (pixels).
185    #[prop(into, optional)]
186    collision_padding: MaybeProp<f64>,
187    #[prop(into, optional)] as_child: MaybeProp<bool>,
188    #[prop(into, optional)] node_ref: AnyNodeRef,
189    children: TypedChildrenFn<impl IntoView + 'static>,
190) -> impl IntoView {
191    let children = StoredValue::new(children.into_inner());
192
193    let popper_side = Signal::derive(move || parse_side(&side.get().unwrap_or("bottom".into())));
194    let popper_side_offset = Signal::derive(move || side_offset.get().unwrap_or(0.0));
195    let popper_align = Signal::derive(move || parse_align(&align.get().unwrap_or("center".into())));
196    let popper_align_offset = Signal::derive(move || align_offset.get().unwrap_or(0.0));
197    let popper_avoid_collisions = Signal::derive(move || avoid_collisions.get().unwrap_or(true));
198    let popper_collision_padding =
199        Signal::derive(move || Padding::All(collision_padding.get().unwrap_or(0.0)));
200
201    let ctx = expect_context::<MenuContextValue>();
202    // loop defaults to true; focus_menu_item always wraps (matching Radix default)
203    let _do_loop = Signal::derive(move || r#loop.get().unwrap_or(true));
204    let present = Signal::derive(move || ctx.open.get());
205    let presence = use_presence(present);
206
207    let focus_ref = use_focus_scope(Signal::derive(|| true), Signal::derive(|| true), None, None);
208    let dismiss_ref = use_dismissable_layer(
209        on_escape_key_down,
210        on_pointer_down_outside,
211        None,
212        None,
213        Some(Callback::new(move |()| ctx.on_open_change.run(false))),
214        Signal::derive(move || !ctx.open.get()),
215    );
216    let refs = use_composed_refs(vec![node_ref, presence.node_ref, focus_ref, dismiss_ref]);
217
218    let content_id = StoredValue::new(ctx.content_id.clone());
219    let search_buffer: RwSignal<String> = RwSignal::new(String::new());
220    let search_timer: RwSignal<Option<i32>> = RwSignal::new(None);
221
222    view! {
223        <Show when=move || presence.is_present.get()>
224            <PopperContent
225                side=popper_side
226                side_offset=popper_side_offset
227                align=popper_align
228                align_offset=popper_align_offset
229                avoid_collisions=popper_avoid_collisions
230                collision_padding=popper_collision_padding
231            >
232                <Primitive element=html::div as_child=as_child node_ref=refs
233                    attr:id=content_id.get_value()
234                    attr:role="menu"
235                    attr:aria-orientation="vertical"
236                    attr:data-state=move || if ctx.open.get() { "open" } else { "closed" }
237                    attr:tabindex="-1"
238                    on:keydown=move |event: KeyboardEvent| {
239                        match event.key().as_str() {
240                            "Tab" => { event.prevent_default(); }
241                            "ArrowDown" | "PageDown" => { event.prevent_default(); focus_menu_item(&event, true); }
242                            "ArrowUp" | "PageUp" => { event.prevent_default(); focus_menu_item(&event, false); }
243                            "Home" => { event.prevent_default(); focus_menu_item_edge(&event, true); }
244                            "End" => { event.prevent_default(); focus_menu_item_edge(&event, false); }
245                            key if key.len() == 1 && !event.ctrl_key() && !event.meta_key() => {
246                                handle_typeahead(&event, key, search_buffer, search_timer);
247                            }
248                            _ => {}
249                        }
250                    }
251                >
252                    {children.with_value(|c| c())}
253                </Primitive>
254            </PopperContent>
255        </Show>
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Group
261// ---------------------------------------------------------------------------
262
263#[component]
264pub fn DropdownMenuGroup(
265    #[prop(into, optional)] as_child: MaybeProp<bool>,
266    #[prop(into, optional)] node_ref: AnyNodeRef,
267    children: TypedChildrenFn<impl IntoView + 'static>,
268) -> impl IntoView {
269    let children = StoredValue::new(children.into_inner());
270    view! {
271        <Primitive element=html::div as_child=as_child node_ref=node_ref attr:role="group">
272            {children.with_value(|c| c())}
273        </Primitive>
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Label
279// ---------------------------------------------------------------------------
280
281#[component]
282pub fn DropdownMenuLabel(
283    #[prop(into, optional)] as_child: MaybeProp<bool>,
284    #[prop(into, optional)] node_ref: AnyNodeRef,
285    children: TypedChildrenFn<impl IntoView + 'static>,
286) -> impl IntoView {
287    let children = StoredValue::new(children.into_inner());
288    view! {
289        <Primitive element=html::div as_child=as_child node_ref=node_ref>
290            {children.with_value(|c| c())}
291        </Primitive>
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Item
297// ---------------------------------------------------------------------------
298
299#[component]
300pub fn DropdownMenuItem(
301    #[prop(into, optional)] disabled: MaybeProp<bool>,
302    #[prop(into, optional)] on_select: Option<Callback<()>>,
303    #[prop(into, optional)] as_child: MaybeProp<bool>,
304    #[prop(into, optional)] node_ref: AnyNodeRef,
305    children: TypedChildrenFn<impl IntoView + 'static>,
306) -> impl IntoView {
307    let children = StoredValue::new(children.into_inner());
308    let ctx = expect_context::<MenuContextValue>();
309    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
310
311    let handle_select = move || {
312        if !disabled.get() {
313            if let Some(on_select) = on_select {
314                on_select.run(());
315            }
316            ctx.on_open_change.run(false);
317        }
318    };
319
320    view! {
321        <Primitive element=html::div as_child=as_child node_ref=node_ref
322            attr:role="menuitem"
323            attr:data-disabled=move || disabled.get().then_some("")
324            attr:tabindex="-1"
325            on:click=move |_| handle_select()
326            on:keydown=move |event: KeyboardEvent| {
327                if matches!(event.key().as_str(), "Enter" | " ") && !disabled.get() {
328                    event.prevent_default();
329                    handle_select();
330                }
331            }
332        >
333            {children.with_value(|c| c())}
334        </Primitive>
335    }
336}
337
338// ---------------------------------------------------------------------------
339// CheckboxItem
340// ---------------------------------------------------------------------------
341
342#[component]
343pub fn DropdownMenuCheckboxItem(
344    #[prop(into, optional)] checked: MaybeProp<bool>,
345    #[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
346    #[prop(into, optional)] disabled: MaybeProp<bool>,
347    #[prop(into, optional)] on_select: Option<Callback<()>>,
348    #[prop(into, optional)] as_child: MaybeProp<bool>,
349    #[prop(into, optional)] node_ref: AnyNodeRef,
350    children: TypedChildrenFn<impl IntoView + 'static>,
351) -> impl IntoView {
352    let children = StoredValue::new(children.into_inner());
353    let ctx = expect_context::<MenuContextValue>();
354    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
355    let checked = Signal::derive(move || checked.get().unwrap_or(false));
356
357    let item_checked_ctx = MenuItemCheckedContextValue { checked };
358
359    view! {
360        <Provider value=item_checked_ctx>
361            <Primitive element=html::div as_child=as_child node_ref=node_ref
362                attr:role="menuitemcheckbox"
363                attr:aria-checked=move || checked.get().to_string()
364                attr:data-state=move || if checked.get() { "checked" } else { "unchecked" }
365                attr:data-disabled=move || disabled.get().then_some("")
366                attr:tabindex="-1"
367                on:click=move |_| {
368                    if !disabled.get() {
369                        if let Some(cb) = on_checked_change { cb.run(!checked.get()); }
370                        if let Some(cb) = on_select { cb.run(()); }
371                        ctx.on_open_change.run(false);
372                    }
373                }
374                on:keydown=move |event: KeyboardEvent| {
375                    if matches!(event.key().as_str(), "Enter" | " ") && !disabled.get() {
376                        event.prevent_default();
377                        if let Some(cb) = on_checked_change { cb.run(!checked.get()); }
378                        if let Some(cb) = on_select { cb.run(()); }
379                        ctx.on_open_change.run(false);
380                    }
381                }
382            >
383                {children.with_value(|c| c())}
384            </Primitive>
385        </Provider>
386    }
387}
388
389// ---------------------------------------------------------------------------
390// RadioGroup + RadioItem
391// ---------------------------------------------------------------------------
392
393#[component]
394pub fn DropdownMenuRadioGroup(
395    #[prop(into, optional)] value: MaybeProp<String>,
396    #[prop(into, optional)] on_value_change: Option<Callback<String>>,
397    #[prop(into, optional)] as_child: MaybeProp<bool>,
398    #[prop(into, optional)] node_ref: AnyNodeRef,
399    children: TypedChildrenFn<impl IntoView + 'static>,
400) -> impl IntoView {
401    let children = StoredValue::new(children.into_inner());
402    let value = Signal::derive(move || value.get());
403    let radio_ctx = MenuRadioGroupContextValue {
404        value,
405        on_value_change: Callback::new(move |v: String| {
406            if let Some(cb) = on_value_change {
407                cb.run(v);
408            }
409        }),
410    };
411
412    view! {
413        <Provider value=radio_ctx>
414            <Primitive element=html::div as_child=as_child node_ref=node_ref attr:role="group">
415                {children.with_value(|c| c())}
416            </Primitive>
417        </Provider>
418    }
419}
420
421#[component]
422pub fn DropdownMenuRadioItem(
423    #[prop(into)] value: String,
424    #[prop(into, optional)] disabled: MaybeProp<bool>,
425    #[prop(into, optional)] on_select: Option<Callback<()>>,
426    #[prop(into, optional)] as_child: MaybeProp<bool>,
427    #[prop(into, optional)] node_ref: AnyNodeRef,
428    children: TypedChildrenFn<impl IntoView + 'static>,
429) -> impl IntoView {
430    let children = StoredValue::new(children.into_inner());
431    let ctx = expect_context::<MenuContextValue>();
432    let radio_ctx = expect_context::<MenuRadioGroupContextValue>();
433    let item_value = value.clone();
434    let item_value_click = value.clone();
435    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
436    let checked =
437        Signal::derive(move || radio_ctx.value.get().as_deref() == Some(item_value.as_str()));
438    let item_checked_ctx = MenuItemCheckedContextValue { checked };
439
440    view! {
441        <Provider value=item_checked_ctx>
442            <Primitive element=html::div as_child=as_child node_ref=node_ref
443                attr:role="menuitemradio"
444                attr:aria-checked=move || checked.get().to_string()
445                attr:data-state=move || if checked.get() { "checked" } else { "unchecked" }
446                attr:data-disabled=move || disabled.get().then_some("")
447                attr:tabindex="-1"
448                on:click=move |_| {
449                    if !disabled.get() {
450                        radio_ctx.on_value_change.run(item_value_click.clone());
451                        if let Some(cb) = on_select { cb.run(()); }
452                        ctx.on_open_change.run(false);
453                    }
454                }
455                on:keydown=move |event: KeyboardEvent| {
456                    if matches!(event.key().as_str(), "Enter" | " ") && !disabled.get() {
457                        event.prevent_default();
458                        radio_ctx.on_value_change.run(value.clone());
459                        if let Some(cb) = on_select { cb.run(()); }
460                        ctx.on_open_change.run(false);
461                    }
462                }
463            >
464                {children.with_value(|c| c())}
465            </Primitive>
466        </Provider>
467    }
468}
469
470// ---------------------------------------------------------------------------
471// ItemIndicator
472// ---------------------------------------------------------------------------
473
474#[component]
475pub fn DropdownMenuItemIndicator(
476    #[prop(into, optional)] force_mount: MaybeProp<bool>,
477    #[prop(into, optional)] as_child: MaybeProp<bool>,
478    #[prop(into, optional)] node_ref: AnyNodeRef,
479    #[prop(optional)] children: Option<ChildrenFn>,
480) -> impl IntoView {
481    let children = StoredValue::new(children);
482    let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
483    let checked_ctx = expect_context::<MenuItemCheckedContextValue>();
484
485    view! {
486        <Show when=move || force_mount.get() || checked_ctx.checked.get()>
487            <Primitive element=html::span as_child=as_child node_ref=node_ref
488                attr:data-state=move || if checked_ctx.checked.get() { "checked" } else { "unchecked" }
489            >
490                {children.with_value(|c| c.as_ref().map(|c| c()))}
491            </Primitive>
492        </Show>
493    }
494}
495
496// ---------------------------------------------------------------------------
497// Separator
498// ---------------------------------------------------------------------------
499
500#[component]
501pub fn DropdownMenuSeparator(
502    #[prop(into, optional)] as_child: MaybeProp<bool>,
503    #[prop(into, optional)] node_ref: AnyNodeRef,
504) -> impl IntoView {
505    view! {
506        <Primitive element=html::div as_child=as_child node_ref=node_ref
507            attr:role="separator"
508            attr:aria-orientation="horizontal"
509        >
510            {""}
511        </Primitive>
512    }
513}
514
515// ---------------------------------------------------------------------------
516// Arrow
517// ---------------------------------------------------------------------------
518
519#[component]
520pub fn DropdownMenuArrow(
521    #[prop(into, optional)] width: MaybeProp<f64>,
522    #[prop(into, optional)] height: MaybeProp<f64>,
523    #[prop(into, optional)] as_child: MaybeProp<bool>,
524    #[prop(into, optional)] node_ref: AnyNodeRef,
525    #[prop(optional)] children: Option<ChildrenFn>,
526) -> impl IntoView {
527    let children = StoredValue::new(children);
528    view! {
529        <PopperArrow width=width height=height as_child=as_child node_ref=node_ref>
530            {children.with_value(|c| c.as_ref().map(|c| c()))}
531        </PopperArrow>
532    }
533}
534
535// ---------------------------------------------------------------------------
536// Sub / SubTrigger / SubContent
537// ---------------------------------------------------------------------------
538
539#[component]
540pub fn DropdownMenuSub(
541    #[prop(into, optional)] open: MaybeProp<bool>,
542    #[prop(into, optional)] default_open: MaybeProp<bool>,
543    #[prop(into, optional)] on_open_change: Option<Callback<bool>>,
544    children: TypedChildrenFn<impl IntoView + 'static>,
545) -> impl IntoView {
546    let children = StoredValue::new(children.into_inner());
547    let open_state = RwSignal::new(open.get().or(default_open.get()).unwrap_or(false));
548
549    Effect::new(move |_| {
550        if let Some(o) = open.get() {
551            open_state.set(o);
552        }
553    });
554    Effect::new(move |_| {
555        if let Some(cb) = on_open_change {
556            cb.run(open_state.get());
557        }
558    });
559
560    let base_id = use_id(None).get();
561    let sub_ctx = SubMenuContextValue {
562        open: open_state,
563        content_id: format!("{}-sub", base_id),
564        trigger_ref: AnyNodeRef::new(),
565    };
566
567    view! {
568        <Provider value=sub_ctx>
569            <Popper>
570                {children.with_value(|c| c())}
571            </Popper>
572        </Provider>
573    }
574}
575
576#[component]
577pub fn DropdownMenuSubTrigger(
578    #[prop(into, optional)] disabled: MaybeProp<bool>,
579    #[prop(into, optional)] as_child: MaybeProp<bool>,
580    #[prop(into, optional)] node_ref: AnyNodeRef,
581    children: TypedChildrenFn<impl IntoView + 'static>,
582) -> impl IntoView {
583    let children = StoredValue::new(children.into_inner());
584    let menu_ctx = expect_context::<MenuContextValue>();
585    let sub_ctx = expect_context::<SubMenuContextValue>();
586    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
587    let refs = use_composed_refs(vec![node_ref, sub_ctx.trigger_ref]);
588    let sub_content_id = StoredValue::new(sub_ctx.content_id.clone());
589
590    view! {
591        <PopperAnchor as_child=MaybeProp::derive(|| Some(true))>
592            <Primitive element=html::div as_child=as_child node_ref=refs
593                attr:role="menuitem"
594                attr:aria-haspopup="menu"
595                attr:aria-expanded=move || sub_ctx.open.get().to_string()
596                attr:aria-controls=move || sub_ctx.open.get().then(|| sub_content_id.get_value())
597                attr:data-state=move || if sub_ctx.open.get() { "open" } else { "closed" }
598                attr:data-disabled=move || disabled.get().then_some("")
599                attr:tabindex="-1"
600                on:click=move |_| {
601                    if !disabled.get() { sub_ctx.open.set(!sub_ctx.open.get()); }
602                }
603                on:pointerenter=move |_| {
604                    if !disabled.get() { sub_ctx.open.set(true); }
605                }
606                on:keydown=move |event: KeyboardEvent| {
607                    let open_key = if menu_ctx.dir.get() == leptix_core::direction::Direction::Rtl { "ArrowLeft" } else { "ArrowRight" };
608                    if event.key() == open_key && !disabled.get() {
609                        event.prevent_default();
610                        sub_ctx.open.set(true);
611                    }
612                }
613            >
614                {children.with_value(|c| c())}
615            </Primitive>
616        </PopperAnchor>
617    }
618}
619
620#[component]
621pub fn DropdownMenuSubContent(
622    #[prop(into, optional)] force_mount: MaybeProp<bool>,
623    #[prop(into, optional)] side_offset: MaybeProp<f64>,
624    #[prop(into, optional)] as_child: MaybeProp<bool>,
625    #[prop(into, optional)] node_ref: AnyNodeRef,
626    children: TypedChildrenFn<impl IntoView + 'static>,
627) -> impl IntoView {
628    let children = StoredValue::new(children.into_inner());
629    let menu_ctx = expect_context::<MenuContextValue>();
630    let sub_ctx = expect_context::<SubMenuContextValue>();
631    let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
632    let present = Signal::derive(move || force_mount.get() || sub_ctx.open.get());
633    let presence = use_presence(present);
634
635    // Default sub-menu side: right for LTR, left for RTL
636    let popper_side = Signal::derive(move || {
637        if menu_ctx.dir.get() == leptix_core::direction::Direction::Rtl {
638            parse_side("left")
639        } else {
640            parse_side("right")
641        }
642    });
643    let popper_side_offset = Signal::derive(move || side_offset.get().unwrap_or(0.0));
644    let popper_align = Signal::derive(|| parse_align("start"));
645    let popper_align_offset = Signal::derive(|| 0.0);
646    let popper_avoid_collisions = Signal::derive(|| true);
647    let popper_collision_padding = Signal::derive(|| Padding::All(0.0));
648
649    let dismiss_ref = use_dismissable_layer(
650        None,
651        None,
652        None,
653        None,
654        Some(Callback::new(move |()| sub_ctx.open.set(false))),
655        Signal::derive(move || !sub_ctx.open.get()),
656    );
657    let refs = use_composed_refs(vec![node_ref, presence.node_ref, dismiss_ref]);
658
659    // Safe triangle: when the pointer leaves the sub-content, compute a triangle
660    // from the exit point to the two far corners of the sub-trigger. Keep the
661    // sub-menu open while the pointer remains inside that triangle (checked via
662    // document-level pointermove). Close when the pointer exits the triangle or
663    // after a 400ms safety timeout.
664    let grace_timer: RwSignal<Option<i32>> = RwSignal::new(None);
665    // Store the safe triangle polygon: 3 points [(x,y); 3]
666    let safe_triangle: RwSignal<Option<[(f64, f64); 3]>> = RwSignal::new(None);
667
668    let clear_grace = move || {
669        if let Some(id) = grace_timer.get_untracked()
670            && let Some(w) = web_sys::window()
671        {
672            w.clear_timeout_with_handle(id);
673        }
674        grace_timer.set(None);
675        safe_triangle.set(None);
676    };
677
678    on_cleanup(clear_grace);
679
680    view! {
681        <Show when=move || presence.is_present.get()>
682            <PopperContent
683                side=popper_side
684                side_offset=popper_side_offset
685                align=popper_align
686                align_offset=popper_align_offset
687                avoid_collisions=popper_avoid_collisions
688                collision_padding=popper_collision_padding
689                as_child=as_child
690                node_ref=refs
691                attr:id=sub_ctx.content_id.clone()
692                attr:role="menu"
693                attr:aria-orientation="vertical"
694                attr:data-state=move || if sub_ctx.open.get() { "open" } else { "closed" }
695                attr:tabindex="-1"
696                on:keydown=move |event: KeyboardEvent| {
697                    let close_key = if menu_ctx.dir.get() == leptix_core::direction::Direction::Rtl { "ArrowRight" } else { "ArrowLeft" };
698                    match event.key().as_str() {
699                        "ArrowDown" => { event.prevent_default(); focus_menu_item(&event, true); }
700                        "ArrowUp" => { event.prevent_default(); focus_menu_item(&event, false); }
701                        k if k == close_key => { event.prevent_default(); sub_ctx.open.set(false); }
702                        "Escape" => { sub_ctx.open.set(false); }
703                        _ => {}
704                    }
705                }
706                on:pointerenter=move |_| { clear_grace(); }
707                on:pointerleave=move |event: web_sys::PointerEvent| {
708                    clear_grace();
709                    let px = event.client_x() as f64;
710                    let py = event.client_y() as f64;
711
712                    // Compute safe triangle: from pointer exit point to the two
713                    // far corners of the sub-trigger (the side facing the content).
714                    let triangle = sub_ctx.trigger_ref.get().map(|trigger| {
715                        let r = trigger.get_bounding_client_rect();
716                        let is_rtl = menu_ctx.dir.get() == leptix_core::direction::Direction::Rtl;
717                        // Content opens to the right (LTR) or left (RTL) of trigger.
718                        // The two "far" corners are the trigger edge facing the content.
719                        if is_rtl {
720                            // Content is to the left, trigger's left edge faces content
721                            [(px, py), (r.left(), r.top()), (r.left(), r.bottom())]
722                        } else {
723                            // Content is to the right, trigger's right edge faces content
724                            [(px, py), (r.right(), r.top()), (r.right(), r.bottom())]
725                        }
726                    });
727
728                    safe_triangle.set(triangle);
729
730                    // Install document-level pointermove to check if pointer is in triangle.
731                    // If pointer exits triangle, close immediately.
732                    if let Some(document) = web_sys::window().and_then(|w| w.document()) {
733                        let handler = web_sys::wasm_bindgen::closure::Closure::<dyn Fn(web_sys::PointerEvent)>::new(
734                            move |ev: web_sys::PointerEvent| {
735                                let mx = ev.client_x() as f64;
736                                let my = ev.client_y() as f64;
737                                if let Some(tri) = safe_triangle.get_untracked() {
738                                    if !point_in_triangle(mx, my, tri) {
739                                        // Pointer left the safe triangle — close immediately
740                                        clear_grace();
741                                        sub_ctx.open.set(false);
742                                    }
743                                }
744                            },
745                        );
746                        let _ = document.add_event_listener_with_callback(
747                            "pointermove",
748                            handler.as_ref().unchecked_ref(),
749                        );
750                        // Remove listener when grace period ends or sub-menu re-entered
751                        // (handled via clear_grace + cleanup). Leak is bounded by the
752                        // 400ms timeout below which always fires.
753                        handler.forget();
754                    }
755
756                    // Safety timeout: close after 400ms regardless
757                    let id = web_sys::window().and_then(|w| {
758                        w.set_timeout_with_callback_and_timeout_and_arguments_0(
759                            web_sys::wasm_bindgen::closure::Closure::<dyn Fn()>::new(move || {
760                                if safe_triangle.get_untracked().is_some() {
761                                    safe_triangle.set(None);
762                                    sub_ctx.open.set(false);
763                                }
764                            })
765                            .into_js_value()
766                            .unchecked_ref(),
767                            400,
768                        )
769                        .ok()
770                    });
771                    grace_timer.set(id);
772                }
773            >
774                {children.with_value(|c| c())}
775            </PopperContent>
776        </Show>
777    }
778}
779
780// ---------------------------------------------------------------------------
781// Helpers
782// ---------------------------------------------------------------------------
783
784fn focus_menu_item(event: &KeyboardEvent, forward: bool) {
785    let Some(container) = event.current_target().and_then(|t| {
786        use web_sys::wasm_bindgen::JsCast;
787        t.dyn_into::<web_sys::Element>().ok()
788    }) else {
789        return;
790    };
791
792    let Ok(items) =
793        container.query_selector_all("[role='menuitem']:not([data-disabled]), [role='menuitemcheckbox']:not([data-disabled]), [role='menuitemradio']:not([data-disabled])")
794    else {
795        return;
796    };
797    let mut nodes = vec![];
798    for i in 0..items.length() {
799        if let Some(n) = items.item(i) {
800            nodes.push(n);
801        }
802    }
803
804    let active = web_sys::window()
805        .and_then(|w| w.document())
806        .and_then(|d| d.active_element());
807    let idx = active.as_ref().and_then(|a| {
808        use web_sys::wasm_bindgen::JsCast;
809        let n: &web_sys::Node = a.unchecked_ref();
810        nodes.iter().position(|node| node == n)
811    });
812
813    let next = if forward {
814        idx.map(|i| i + 1).filter(|i| *i < nodes.len()).or(Some(0))
815    } else {
816        idx.and_then(|i| i.checked_sub(1))
817            .or(Some(nodes.len().saturating_sub(1)))
818    };
819
820    if let Some(idx) = next
821        && let Some(node) = nodes.get(idx)
822    {
823        use web_sys::wasm_bindgen::JsCast;
824        if let Ok(el) = node.clone().dyn_into::<web_sys::HtmlElement>() {
825            let _ = el.focus();
826        }
827    }
828}
829
830/// Focus the first or last menu item.
831fn focus_menu_item_edge(event: &KeyboardEvent, first: bool) {
832    let Some(container) = event.current_target().and_then(|t| {
833        use web_sys::wasm_bindgen::JsCast;
834        t.dyn_into::<web_sys::Element>().ok()
835    }) else {
836        return;
837    };
838
839    let Ok(items) =
840        container.query_selector_all("[role='menuitem']:not([data-disabled]), [role='menuitemcheckbox']:not([data-disabled]), [role='menuitemradio']:not([data-disabled])")
841    else {
842        return;
843    };
844
845    let target_idx = if first {
846        0
847    } else {
848        items.length().saturating_sub(1)
849    };
850    if let Some(node) = items.item(target_idx) {
851        use web_sys::wasm_bindgen::JsCast;
852        if let Ok(el) = node.dyn_into::<web_sys::HtmlElement>() {
853            let _ = el.focus();
854        }
855    }
856}
857
858/// Typeahead search: accumulate typed characters and focus matching item.
859fn handle_typeahead(
860    event: &KeyboardEvent,
861    key: &str,
862    search_buffer: RwSignal<String>,
863    search_timer: RwSignal<Option<i32>>,
864) {
865    let Some(container) = event.current_target().and_then(|t| {
866        use web_sys::wasm_bindgen::JsCast;
867        t.dyn_into::<web_sys::Element>().ok()
868    }) else {
869        return;
870    };
871
872    // Clear previous timer and start a new 1-second reset
873    if let Some(id) = search_timer.get_untracked()
874        && let Some(w) = web_sys::window()
875    {
876        w.clear_timeout_with_handle(id);
877    }
878    search_buffer.update(|buf| buf.push_str(key));
879
880    let id = web_sys::window().and_then(|w| {
881        w.set_timeout_with_callback_and_timeout_and_arguments_0(
882            web_sys::wasm_bindgen::closure::Closure::<dyn Fn()>::new(move || {
883                search_buffer.set(String::new());
884            })
885            .into_js_value()
886            .unchecked_ref(),
887            1000,
888        )
889        .ok()
890    });
891    search_timer.set(id);
892
893    let search = search_buffer.get_untracked().to_lowercase();
894    let Ok(items) =
895        container.query_selector_all("[role='menuitem']:not([data-disabled]), [role='menuitemcheckbox']:not([data-disabled]), [role='menuitemradio']:not([data-disabled])")
896    else {
897        return;
898    };
899
900    // Find the first item whose text starts with the search string
901    for i in 0..items.length() {
902        if let Some(node) = items.item(i) {
903            use web_sys::wasm_bindgen::JsCast;
904            if let Some(text) = node.text_content()
905                && text.trim().to_lowercase().starts_with(&search)
906                && let Ok(el) = node.dyn_into::<web_sys::HtmlElement>()
907            {
908                let _ = el.focus();
909                return;
910            }
911        }
912    }
913}
914
915/// Test if point (px, py) is inside triangle [(x1,y1), (x2,y2), (x3,y3)]
916/// using the sign-of-cross-product (barycentric) method.
917fn point_in_triangle(px: f64, py: f64, tri: [(f64, f64); 3]) -> bool {
918    let [(x1, y1), (x2, y2), (x3, y3)] = tri;
919
920    let d1 = (px - x2) * (y1 - y2) - (x1 - x2) * (py - y2);
921    let d2 = (px - x3) * (y2 - y3) - (x2 - x3) * (py - y3);
922    let d3 = (px - x1) * (y3 - y1) - (x3 - x1) * (py - y1);
923
924    let has_neg = (d1 < 0.0) || (d2 < 0.0) || (d3 < 0.0);
925    let has_pos = (d1 > 0.0) || (d2 > 0.0) || (d3 > 0.0);
926
927    !(has_neg && has_pos)
928}