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#[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#[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#[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#[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#[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 #[prop(into, optional)]
171 side: MaybeProp<String>,
172 #[prop(into, optional)]
174 side_offset: MaybeProp<f64>,
175 #[prop(into, optional)]
177 align: MaybeProp<String>,
178 #[prop(into, optional)]
180 align_offset: MaybeProp<f64>,
181 #[prop(into, optional)]
183 avoid_collisions: MaybeProp<bool>,
184 #[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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 let grace_timer: RwSignal<Option<i32>> = RwSignal::new(None);
665 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 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 if is_rtl {
720 [(px, py), (r.left(), r.top()), (r.left(), r.bottom())]
722 } else {
723 [(px, py), (r.right(), r.top()), (r.right(), r.bottom())]
725 }
726 });
727
728 safe_triangle.set(triangle);
729
730 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 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 handler.forget();
754 }
755
756 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
780fn 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
830fn 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
858fn 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 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 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
915fn 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}