Skip to main content

fret_ui_kit/primitives/menu/
root.rs

1//! Menu root helpers (Radix-aligned outcomes).
2//!
3//! In Radix, `MenuRoot` provides shared context for menu content and nested submenus.
4//! In Fret, the "open/portal/overlay" concerns live in wrapper components (DropdownMenu, etc),
5//! but we still centralize Menu-specific policy wiring here:
6//! - ensuring submenu models exist within a menu root scope
7//! - installing a timer handler for submenu focus/close delays
8//! - producing a DismissableLayer pointer-move observer for submenu grace intent
9
10use fret_ui::action::{
11    DismissReason, OnCloseAutoFocus, OnDismissRequest, OnDismissiblePointerMove, OnOpenAutoFocus,
12};
13use fret_ui::element::AnyElement;
14use fret_ui::elements::GlobalElementId;
15use fret_ui::{ElementContext, UiHost};
16
17use fret_runtime::Model;
18
19use std::sync::Arc;
20
21use crate::primitives::dismissable_layer;
22use crate::primitives::menu::sub;
23use crate::primitives::portal_inherited;
24use crate::{OverlayController, OverlayPresence, OverlayRequest};
25
26/// Menu initial focus targets (Radix `onOpenAutoFocus` outcomes).
27///
28/// When menu overlays open, Radix distinguishes between pointer-open and keyboard-open:
29/// - Pointer-open: focus the content container and prevent “entry focus”.
30/// - Keyboard-open: allow entry focus (typically the first enabled menu item).
31///
32/// In Fret, we encode this as a pair of optional element targets and choose between them based on
33/// the last observed input modality (ADR 0094).
34#[derive(Debug, Default, Clone, Copy)]
35pub struct MenuInitialFocusTargets {
36    pub keyboard_entry_focus: Option<GlobalElementId>,
37    pub pointer_content_focus: Option<GlobalElementId>,
38}
39
40impl MenuInitialFocusTargets {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    pub fn keyboard_entry_focus(mut self, id: Option<GlobalElementId>) -> Self {
46        self.keyboard_entry_focus = id;
47        self
48    }
49
50    pub fn pointer_content_focus(mut self, id: Option<GlobalElementId>) -> Self {
51        self.pointer_content_focus = id;
52        self
53    }
54}
55
56/// Policy for suppressing close auto-focus based on how a menu overlay was dismissed.
57///
58/// This is primarily intended to prevent "focus stealing" in **non-modal** menu overlays where
59/// outside presses are click-through: the pointer-down may legitimately interact with underlay UI,
60/// and restoring focus back to the trigger would fight that.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct MenuCloseAutoFocusGuardPolicy {
63    /// Prevent close auto-focus when dismissed via an outside press.
64    pub prevent_on_outside_press: bool,
65    /// Prevent close auto-focus when dismissed due to focus moving outside the dismissible layer.
66    pub prevent_on_focus_outside: bool,
67    /// Prevent close auto-focus when dismissed via Escape.
68    pub prevent_on_escape: bool,
69}
70
71impl MenuCloseAutoFocusGuardPolicy {
72    /// Default policy for Radix-style menu overlays.
73    ///
74    /// - Modal overlays (`modal=true`) are not click-through, so outside presses generally should
75    ///   not suppress focus restoration.
76    /// - Non-modal overlays (`modal=false`) are click-through, so outside presses should suppress
77    ///   close auto-focus to avoid stealing focus back to the trigger.
78    pub fn for_modal(modal: bool) -> Self {
79        Self {
80            prevent_on_outside_press: !modal,
81            prevent_on_focus_outside: true,
82            prevent_on_escape: false,
83        }
84    }
85
86    /// Always prevent close auto-focus.
87    pub fn prevent_always() -> Self {
88        Self {
89            prevent_on_outside_press: true,
90            prevent_on_focus_outside: true,
91            prevent_on_escape: true,
92        }
93    }
94
95    pub fn prevent_on_escape(mut self, prevent: bool) -> Self {
96        self.prevent_on_escape = prevent;
97        self
98    }
99}
100
101/// Wrap `on_dismiss_request` to preserve default close behavior and install a close auto-focus
102/// guard that persists across frames.
103///
104/// Notes:
105/// - The returned dismiss handler applies Radix-like defaults: it closes the overlay unless the
106///   request is prevented.
107/// - The returned close hook runs the caller hook (if any) and then applies the guard policy
108///   unless the caller prevented default.
109pub fn menu_close_auto_focus_guard_hooks<H: UiHost>(
110    cx: &mut ElementContext<'_, H>,
111    policy: MenuCloseAutoFocusGuardPolicy,
112    open: Model<bool>,
113    on_dismiss_request: Option<OnDismissRequest>,
114    on_close_auto_focus: Option<OnCloseAutoFocus>,
115) -> (Option<OnDismissRequest>, Option<OnCloseAutoFocus>) {
116    let dismiss_reason = cx.local_model(|| None::<DismissReason>);
117
118    // Clear stale reasons when the overlay is open again (new session).
119    let open_now = cx.app.models().get_copied(&open).unwrap_or(false);
120    if open_now {
121        let _ = cx.app.models_mut().update(&dismiss_reason, |v| *v = None);
122    }
123
124    let dismiss_handler: OnDismissRequest = {
125        let open_for_default_close = open.clone();
126        let dismiss_reason_for_hook = dismiss_reason.clone();
127        Arc::new(move |host, cx, req| {
128            if let Some(user) = on_dismiss_request.as_ref() {
129                user(host, cx, req);
130            }
131
132            if !req.default_prevented() {
133                let should_prevent = match req.reason {
134                    DismissReason::OutsidePress { .. } => policy.prevent_on_outside_press,
135                    DismissReason::FocusOutside => policy.prevent_on_focus_outside,
136                    DismissReason::Escape => policy.prevent_on_escape,
137                    _ => false,
138                };
139                let _ = host.models_mut().update(&dismiss_reason_for_hook, |v| {
140                    *v = should_prevent.then_some(req.reason);
141                });
142                let _ = host
143                    .models_mut()
144                    .update(&open_for_default_close, |v| *v = false);
145            } else {
146                let _ = host
147                    .models_mut()
148                    .update(&dismiss_reason_for_hook, |v| *v = None);
149            }
150        })
151    };
152
153    let on_close_auto_focus: Option<OnCloseAutoFocus> = {
154        let dismiss_reason_for_close = dismiss_reason.clone();
155        let user = on_close_auto_focus.clone();
156        Some(Arc::new(move |host, cx, req| {
157            if let Some(user) = user.as_ref() {
158                user(host, cx, req);
159            }
160
161            let reason = host
162                .models_mut()
163                .read(&dismiss_reason_for_close, |v| *v)
164                .ok()
165                .flatten();
166            let _ = host
167                .models_mut()
168                .update(&dismiss_reason_for_close, |v| *v = None);
169
170            if req.default_prevented() {
171                return;
172            }
173
174            let should_prevent = match reason {
175                Some(DismissReason::OutsidePress { .. }) => policy.prevent_on_outside_press,
176                Some(DismissReason::FocusOutside) => policy.prevent_on_focus_outside,
177                Some(DismissReason::Escape) => policy.prevent_on_escape,
178                _ => false,
179            };
180            if should_prevent {
181                req.prevent_default();
182            }
183        }))
184    };
185
186    (Some(dismiss_handler), on_close_auto_focus)
187}
188
189fn base_menu_overlay_request(
190    id: GlobalElementId,
191    trigger: GlobalElementId,
192    open: Model<bool>,
193    presence: OverlayPresence,
194    children: Vec<AnyElement>,
195    modal: bool,
196) -> OverlayRequest {
197    // Radix menu-like overlays can be "modal" (the default) or non-modal.
198    //
199    // In practice this controls whether outside pointer interactions are allowed while the menu is
200    // open:
201    // - modal: outside pointer events are blocked and outside presses are not click-through.
202    // - non-modal: outside presses are click-through (the underlay can receive the click).
203    let mut req = OverlayRequest::dismissible_popover(id, trigger, open, presence, children);
204    req.consume_outside_pointer_events = modal;
205    req.disable_outside_pointer_events = modal;
206    req
207}
208
209/// A stable per-overlay root name for menu-like popovers.
210///
211/// This is the root naming convention used by shadcn menu wrappers (DropdownMenu, ContextMenu,
212/// Menubar) and is safe to share as a Radix-aligned default.
213pub fn menu_overlay_root_name(id: GlobalElementId) -> String {
214    OverlayController::popover_root_name(id)
215}
216
217/// Ensure submenu models exist and install the menu-root timer handler.
218///
219/// Call this inside the overlay root scope (e.g. `portal_inherited::with_root_name_inheriting(...)`),
220/// so the models are scoped to that root.
221pub fn ensure_submenu<H: UiHost>(
222    cx: &mut ElementContext<'_, H>,
223    timer_handler_element: GlobalElementId,
224    cfg: sub::MenuSubmenuConfig,
225) -> sub::MenuSubmenuModels {
226    let models = sub::ensure_models_for(cx, timer_handler_element);
227    sub::install_timer_handler(cx, timer_handler_element, models.clone(), cfg);
228    models
229}
230
231/// Sync root open state and ensure submenu models exist.
232///
233/// This is a convenience wrapper used by menu wrappers (`DropdownMenu`, `Menubar`, etc) so they
234/// don't have to remember to call both `sub::sync_root_open` and `ensure_submenu` inside the
235/// overlay root scope.
236pub fn sync_root_open_and_ensure_submenu<H: UiHost>(
237    cx: &mut ElementContext<'_, H>,
238    is_open: bool,
239    timer_handler_element: GlobalElementId,
240    cfg: sub::MenuSubmenuConfig,
241) -> sub::MenuSubmenuModels {
242    sub::sync_root_open_for(cx, timer_handler_element, is_open);
243    ensure_submenu(cx, timer_handler_element, cfg)
244}
245
246/// Sync root open state and ensure submenu models exist inside a named overlay root.
247#[track_caller]
248pub fn with_root_name_sync_root_open_and_ensure_submenu<H: UiHost>(
249    cx: &mut ElementContext<'_, H>,
250    root_name: &str,
251    is_open: bool,
252    cfg: sub::MenuSubmenuConfig,
253) -> sub::MenuSubmenuModels {
254    let inherited = portal_inherited::PortalInherited::capture(cx);
255    portal_inherited::with_root_name_inheriting(cx, root_name, inherited, |cx| {
256        sync_root_open_and_ensure_submenu(cx, is_open, cx.root_id(), cfg)
257    })
258}
259
260/// Build a DismissableLayer pointer-move observer that drives submenu grace intent.
261pub fn submenu_pointer_move_handler(
262    models: sub::MenuSubmenuModels,
263    cfg: sub::MenuSubmenuConfig,
264) -> OnDismissiblePointerMove {
265    dismissable_layer::pointer_move_handler(move |host, acx, mv| {
266        sub::handle_dismissible_pointer_move(host, acx, mv, &models, cfg)
267    })
268}
269
270/// Build a shadcn/Radix-aligned menu overlay request.
271///
272/// Policy:
273/// - Uses non-click-through outside press (`OverlayRequest::dismissible_menu`, ADR 0069).
274/// - Gates initial focus by last input modality (ADR 0094):
275///   - keyboard: allow entry focus (first focusable descendant)
276///   - pointer: focus the content container and prevent entry focus
277pub fn dismissible_menu_request<H: UiHost>(
278    cx: &mut ElementContext<'_, H>,
279    id: GlobalElementId,
280    trigger: GlobalElementId,
281    open: Model<bool>,
282    presence: OverlayPresence,
283    children: Vec<AnyElement>,
284    root_name: String,
285    initial_focus: MenuInitialFocusTargets,
286    on_open_auto_focus: Option<OnOpenAutoFocus>,
287    on_close_auto_focus: Option<OnCloseAutoFocus>,
288    dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
289) -> OverlayRequest {
290    dismissible_menu_request_with_modal(
291        cx,
292        id,
293        trigger,
294        open,
295        presence,
296        children,
297        root_name,
298        initial_focus,
299        on_open_auto_focus,
300        on_close_auto_focus,
301        dismissible_on_pointer_move,
302        true,
303    )
304}
305
306/// Build a shadcn/Radix-aligned menu overlay request that routes dismissals through an optional
307/// dismiss handler (Radix `DismissableLayer` "preventDefault" outcome).
308pub fn dismissible_menu_request_with_dismiss_handler<H: UiHost>(
309    cx: &mut ElementContext<'_, H>,
310    id: GlobalElementId,
311    trigger: GlobalElementId,
312    open: Model<bool>,
313    presence: OverlayPresence,
314    children: Vec<AnyElement>,
315    root_name: String,
316    initial_focus: MenuInitialFocusTargets,
317    on_open_auto_focus: Option<OnOpenAutoFocus>,
318    on_close_auto_focus: Option<OnCloseAutoFocus>,
319    on_dismiss_request: Option<OnDismissRequest>,
320    dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
321) -> OverlayRequest {
322    dismissible_menu_request_with_modal_and_dismiss_handler(
323        cx,
324        id,
325        trigger,
326        open,
327        presence,
328        children,
329        root_name,
330        initial_focus,
331        on_open_auto_focus,
332        on_close_auto_focus,
333        on_dismiss_request,
334        dismissible_on_pointer_move,
335        true,
336    )
337}
338
339/// Build a shadcn/Radix-aligned menu overlay request with explicit modal behavior.
340///
341/// In Radix, the `modal` flag controls `disableOutsidePointerEvents`. In Fret we approximate this
342/// behavior by:
343/// - blocking underlay pointer interaction while open, and
344/// - controlling whether outside-press dismissal is click-through.
345pub fn dismissible_menu_request_with_modal<H: UiHost>(
346    cx: &mut ElementContext<'_, H>,
347    id: GlobalElementId,
348    trigger: GlobalElementId,
349    open: Model<bool>,
350    presence: OverlayPresence,
351    children: Vec<AnyElement>,
352    root_name: String,
353    initial_focus: MenuInitialFocusTargets,
354    on_open_auto_focus: Option<OnOpenAutoFocus>,
355    on_close_auto_focus: Option<OnCloseAutoFocus>,
356    dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
357    modal: bool,
358) -> OverlayRequest {
359    dismissible_menu_request_with_modal_and_dismiss_handler(
360        cx,
361        id,
362        trigger,
363        open,
364        presence,
365        children,
366        root_name,
367        initial_focus,
368        on_open_auto_focus,
369        on_close_auto_focus,
370        None,
371        dismissible_on_pointer_move,
372        modal,
373    )
374}
375
376/// Build a shadcn/Radix-aligned menu overlay request with explicit modal behavior and an optional
377/// dismiss handler.
378pub fn dismissible_menu_request_with_modal_and_dismiss_handler<H: UiHost>(
379    cx: &mut ElementContext<'_, H>,
380    id: GlobalElementId,
381    trigger: GlobalElementId,
382    open: Model<bool>,
383    presence: OverlayPresence,
384    children: Vec<AnyElement>,
385    root_name: String,
386    initial_focus: MenuInitialFocusTargets,
387    on_open_auto_focus: Option<OnOpenAutoFocus>,
388    on_close_auto_focus: Option<OnCloseAutoFocus>,
389    on_dismiss_request: Option<OnDismissRequest>,
390    dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
391    modal: bool,
392) -> OverlayRequest {
393    let mut request = base_menu_overlay_request(id, trigger, open, presence, children, modal);
394    request.root_name = Some(root_name);
395    request.dismissible_on_dismiss_request = on_dismiss_request;
396    request.dismissible_on_pointer_move = dismissible_on_pointer_move;
397    request.on_open_auto_focus = on_open_auto_focus;
398    request.on_close_auto_focus = on_close_auto_focus;
399
400    let keyboard = fret_ui::input_modality::is_keyboard(cx.app, Some(cx.window));
401    request.initial_focus = if keyboard {
402        initial_focus.keyboard_entry_focus
403    } else {
404        initial_focus.pointer_content_focus
405    };
406    request
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    use std::sync::Arc;
414
415    use fret_app::App;
416    use fret_core::{
417        AppWindowId, Event, KeyCode, Modifiers, MouseButtons, Point, PointerEvent, PointerId,
418        PointerType, Px, Rect, Size,
419    };
420
421    #[test]
422    fn menu_modal_controls_underlay_pointer_blocking_and_click_through() {
423        let mut app = App::new();
424        let open = app.models_mut().insert(false);
425
426        let req = base_menu_overlay_request(
427            GlobalElementId(1),
428            GlobalElementId(2),
429            open.clone(),
430            OverlayPresence::hidden(),
431            Vec::new(),
432            true,
433        );
434        assert!(req.consume_outside_pointer_events);
435        assert!(req.disable_outside_pointer_events);
436
437        let req = base_menu_overlay_request(
438            GlobalElementId(1),
439            GlobalElementId(2),
440            open,
441            OverlayPresence::hidden(),
442            Vec::new(),
443            false,
444        );
445        assert!(!req.consume_outside_pointer_events);
446        assert!(!req.disable_outside_pointer_events);
447    }
448
449    #[test]
450    fn menu_request_can_install_dismiss_handler() {
451        let mut app = App::new();
452        let open = app.models_mut().insert(false);
453
454        let window = AppWindowId::default();
455        let bounds = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
456        let handler: OnDismissRequest =
457            Arc::new(|_host, _cx, _req: &mut fret_ui::action::DismissRequestCx| {});
458
459        fret_ui::elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
460            let req = dismissible_menu_request_with_modal_and_dismiss_handler(
461                cx,
462                GlobalElementId(1),
463                GlobalElementId(2),
464                open.clone(),
465                OverlayPresence::hidden(),
466                Vec::new(),
467                "menu".to_string(),
468                MenuInitialFocusTargets::new(),
469                None,
470                None,
471                Some(handler.clone()),
472                None,
473                true,
474            );
475            assert!(req.dismissible_on_dismiss_request.is_some());
476        });
477    }
478
479    #[test]
480    fn menu_request_gates_initial_focus_by_modality() {
481        let mut app = App::new();
482        let open = app.models_mut().insert(false);
483
484        let window = AppWindowId::default();
485        let bounds = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
486
487        let pointer_focus = GlobalElementId(0x111);
488        let keyboard_focus = GlobalElementId(0x222);
489
490        fret_ui::elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
491            // Pointer modality: choose pointer content focus.
492            fret_ui::input_modality::update_for_event(
493                cx.app,
494                window,
495                &Event::Pointer(PointerEvent::Move {
496                    position: Point::new(Px(1.0), Px(2.0)),
497                    buttons: MouseButtons::default(),
498                    modifiers: Modifiers::default(),
499                    pointer_id: PointerId(0),
500                    pointer_type: PointerType::Mouse,
501                }),
502            );
503
504            let req = dismissible_menu_request(
505                cx,
506                GlobalElementId(1),
507                GlobalElementId(2),
508                open.clone(),
509                OverlayPresence::hidden(),
510                Vec::new(),
511                "menu".to_string(),
512                MenuInitialFocusTargets::new()
513                    .pointer_content_focus(Some(pointer_focus))
514                    .keyboard_entry_focus(Some(keyboard_focus)),
515                None,
516                None,
517                None,
518            );
519            assert_eq!(req.initial_focus, Some(pointer_focus));
520
521            // Keyboard modality: choose keyboard entry focus.
522            fret_ui::input_modality::update_for_event(
523                cx.app,
524                window,
525                &Event::KeyDown {
526                    key: KeyCode::KeyA,
527                    modifiers: Modifiers::default(),
528                    repeat: false,
529                },
530            );
531            let req = dismissible_menu_request(
532                cx,
533                GlobalElementId(1),
534                GlobalElementId(2),
535                open.clone(),
536                OverlayPresence::hidden(),
537                Vec::new(),
538                "menu".to_string(),
539                MenuInitialFocusTargets::new()
540                    .pointer_content_focus(Some(pointer_focus))
541                    .keyboard_entry_focus(Some(keyboard_focus)),
542                None,
543                None,
544                None,
545            );
546            assert_eq!(req.initial_focus, Some(keyboard_focus));
547        });
548    }
549}