Skip to main content

fret_ui_kit/primitives/
navigation_menu.rs

1//! NavigationMenu primitives (Radix-aligned outcomes).
2//!
3//! This module provides a stable, Radix-named surface for composing navigation menu behavior in
4//! recipes. It intentionally models outcomes rather than React/DOM APIs.
5//!
6//! Upstream reference:
7//! - `repo-ref/primitives/packages/react/navigation-menu/src/navigation-menu.tsx`
8
9use std::collections::HashMap;
10use std::sync::Arc;
11use std::sync::Mutex;
12use std::time::Duration;
13
14use fret_core::{Modifiers, Point, PointerType, Px, Rect, Size, Transform2D};
15use fret_runtime::{CommandId, Effect, FrameId, Model, TimerToken};
16use fret_ui::action::{ActionCx, OnDismissiblePointerMove, UiActionHost};
17use fret_ui::element::{AnyElement, LayoutStyle};
18use fret_ui::elements::ContinuousFrames;
19use fret_ui::elements::GlobalElementId;
20use fret_ui::overlay_placement::Side;
21use fret_ui::theme::CubicBezier;
22use fret_ui::{ElementContext, UiHost};
23
24use crate::declarative::model_watch::ModelWatchExt;
25use crate::headless::transition::TransitionTimeline;
26use crate::overlay;
27use crate::primitives::popper;
28use crate::primitives::portal_inherited;
29use crate::{OverlayController, OverlayPresence, OverlayRequest};
30
31/// Radix `delayDuration` default (milliseconds).
32pub const DEFAULT_DELAY_DURATION_MS: u64 = 200;
33/// Radix `skipDelayDuration` default (milliseconds).
34pub const DEFAULT_SKIP_DELAY_DURATION_MS: u64 = 300;
35/// Radix `startCloseTimer` default (milliseconds).
36pub const DEFAULT_CLOSE_DELAY_MS: u64 = 150;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct NavigationMenuConfig {
40    pub delay_duration: Duration,
41    pub skip_delay_duration: Duration,
42    pub close_delay_duration: Duration,
43}
44
45impl Default for NavigationMenuConfig {
46    fn default() -> Self {
47        Self {
48            delay_duration: Duration::from_millis(DEFAULT_DELAY_DURATION_MS),
49            skip_delay_duration: Duration::from_millis(DEFAULT_SKIP_DELAY_DURATION_MS),
50            close_delay_duration: Duration::from_millis(DEFAULT_CLOSE_DELAY_MS),
51        }
52    }
53}
54
55impl NavigationMenuConfig {
56    pub fn new(
57        delay_duration: Duration,
58        skip_delay_duration: Duration,
59        close_delay_duration: Duration,
60    ) -> Self {
61        Self {
62            delay_duration,
63            skip_delay_duration,
64            close_delay_duration,
65        }
66    }
67}
68
69/// Returns a selected-value model that behaves like Radix `useControllableState` (`value` /
70/// `defaultValue`).
71///
72/// Radix uses an empty string to represent "closed". In Fret we use `Option<Arc<str>>` (`None`
73/// means closed).
74pub fn navigation_menu_use_value_model<H: UiHost>(
75    cx: &mut ElementContext<'_, H>,
76    controlled: Option<Model<Option<Arc<str>>>>,
77    default_value: impl FnOnce() -> Option<Arc<str>>,
78) -> crate::primitives::controllable_state::ControllableModel<Option<Arc<str>>> {
79    crate::primitives::controllable_state::use_controllable_model(cx, controlled, default_value)
80}
81
82#[derive(Default)]
83struct TriggerIdRegistry {
84    ids: HashMap<Arc<str>, GlobalElementId>,
85}
86
87#[derive(Default)]
88struct TriggerStateRegistry {
89    states: HashMap<Arc<str>, NavigationMenuTriggerState>,
90}
91
92#[derive(Default)]
93struct EntryFocusRegistry {
94    frame_id: Option<FrameId>,
95    first_link_ids: HashMap<Arc<str>, GlobalElementId>,
96}
97
98fn navigation_menu_entry_focus_registry<H: UiHost>(
99    cx: &mut ElementContext<'_, H>,
100    root_id: GlobalElementId,
101) -> Arc<Mutex<EntryFocusRegistry>> {
102    cx.state_for(
103        root_id,
104        || Arc::new(Mutex::new(EntryFocusRegistry::default())),
105        |s| s.clone(),
106    )
107}
108
109fn find_first_focus_target(elements: &[AnyElement]) -> Option<GlobalElementId> {
110    let mut stack: Vec<&AnyElement> = elements.iter().rev().collect();
111    while let Some(el) = stack.pop() {
112        match &el.kind {
113            fret_ui::element::ElementKind::Pressable(props) if props.enabled => return Some(el.id),
114            fret_ui::element::ElementKind::TextInput(props) if props.enabled => return Some(el.id),
115            fret_ui::element::ElementKind::TextArea(props) if props.enabled => return Some(el.id),
116            fret_ui::element::ElementKind::TextInputRegion(props) if props.enabled => {
117                return Some(el.id);
118            }
119            _ => {}
120        }
121        for child in el.children.iter().rev() {
122            stack.push(child);
123        }
124    }
125    None
126}
127
128/// Registers a rendered trigger element id for the given value.
129///
130/// Recipes can use this to position an indicator or viewport relative to the active trigger.
131pub fn navigation_menu_register_trigger_id<H: UiHost>(
132    cx: &mut ElementContext<'_, H>,
133    root_id: GlobalElementId,
134    value: Arc<str>,
135    trigger_id: GlobalElementId,
136) {
137    cx.state_for(root_id, TriggerIdRegistry::default, |st| {
138        st.ids.insert(value, trigger_id);
139    });
140}
141
142/// Returns the last registered trigger element id for a value.
143pub fn navigation_menu_trigger_id<H: UiHost>(
144    cx: &mut ElementContext<'_, H>,
145    root_id: GlobalElementId,
146    value: &str,
147) -> Option<GlobalElementId> {
148    cx.state_for(root_id, TriggerIdRegistry::default, |st| {
149        st.ids.get(value).copied()
150    })
151}
152
153#[derive(Default)]
154struct ViewportContentIdRegistry {
155    ids: HashMap<Arc<str>, GlobalElementId>,
156}
157
158#[derive(Default)]
159struct ViewportPanelIdRegistry {
160    id: Option<GlobalElementId>,
161}
162
163#[derive(Default)]
164struct IndicatorTrackIdRegistry {
165    id: Option<GlobalElementId>,
166}
167
168#[derive(Default)]
169struct IndicatorDiamondIdRegistry {
170    id: Option<GlobalElementId>,
171}
172/// Registers the viewport content element id for a given value.
173///
174/// This mirrors Radix's internal "viewport content map" concept: each content instance is keyed by
175/// `value` so other parts (viewport sizing, indicator, focus proxies) can look up the last known
176/// element id without reaching into recipe-local state.
177pub fn navigation_menu_register_viewport_content_id<H: UiHost>(
178    cx: &mut ElementContext<'_, H>,
179    root_id: GlobalElementId,
180    value: Arc<str>,
181    content_id: GlobalElementId,
182) {
183    cx.state_for(root_id, ViewportContentIdRegistry::default, |st| {
184        st.ids.insert(value, content_id);
185    });
186}
187
188/// Returns the last registered viewport content element id for a value.
189pub fn navigation_menu_viewport_content_id<H: UiHost>(
190    cx: &mut ElementContext<'_, H>,
191    root_id: GlobalElementId,
192    value: &str,
193) -> Option<GlobalElementId> {
194    cx.state_for(root_id, ViewportContentIdRegistry::default, |st| {
195        st.ids.get(value).copied()
196    })
197}
198
199/// Registers the viewport panel element id for the root.
200///
201/// This mirrors the Radix `NavigationMenuViewport` element: a single panel that hosts the active
202/// content and animates its size while opening/closing.
203pub fn navigation_menu_register_viewport_panel_id<H: UiHost>(
204    cx: &mut ElementContext<'_, H>,
205    root_id: GlobalElementId,
206    viewport_panel_id: GlobalElementId,
207) {
208    cx.state_for(root_id, ViewportPanelIdRegistry::default, |st| {
209        st.id = Some(viewport_panel_id);
210    });
211}
212
213/// Returns the last registered viewport panel element id for the root.
214pub fn navigation_menu_viewport_panel_id<H: UiHost>(
215    cx: &mut ElementContext<'_, H>,
216    root_id: GlobalElementId,
217) -> Option<GlobalElementId> {
218    cx.state_for(root_id, ViewportPanelIdRegistry::default, |st| st.id)
219}
220
221/// Registers the indicator track element id for the root.
222///
223/// Recipes can use this to associate diagnostics / golden assertions with the rendered indicator.
224pub fn navigation_menu_register_indicator_track_id<H: UiHost>(
225    cx: &mut ElementContext<'_, H>,
226    root_id: GlobalElementId,
227    indicator_track_id: GlobalElementId,
228) {
229    cx.state_for(root_id, IndicatorTrackIdRegistry::default, |st| {
230        st.id = Some(indicator_track_id);
231    });
232}
233
234/// Returns the last registered indicator track element id for the root.
235pub fn navigation_menu_indicator_track_id<H: UiHost>(
236    cx: &mut ElementContext<'_, H>,
237    root_id: GlobalElementId,
238) -> Option<GlobalElementId> {
239    cx.state_for(root_id, IndicatorTrackIdRegistry::default, |st| st.id)
240}
241
242/// Registers the indicator diamond element id for the root.
243pub fn navigation_menu_register_indicator_diamond_id<H: UiHost>(
244    cx: &mut ElementContext<'_, H>,
245    root_id: GlobalElementId,
246    indicator_diamond_id: GlobalElementId,
247) {
248    cx.state_for(root_id, IndicatorDiamondIdRegistry::default, |st| {
249        st.id = Some(indicator_diamond_id);
250    });
251}
252
253/// Returns the last registered indicator diamond element id for the root.
254pub fn navigation_menu_indicator_diamond_id<H: UiHost>(
255    cx: &mut ElementContext<'_, H>,
256    root_id: GlobalElementId,
257) -> Option<GlobalElementId> {
258    cx.state_for(root_id, IndicatorDiamondIdRegistry::default, |st| st.id)
259}
260
261fn navigation_menu_viewport_content_semantics_id_in_scope<H: UiHost>(
262    cx: &mut ElementContext<'_, H>,
263    root_id: GlobalElementId,
264    value: &str,
265) -> GlobalElementId {
266    navigation_menu_viewport_content_pressable_with_id_props::<H>(
267        cx,
268        root_id,
269        value,
270        |_cx, _st, _id| {
271            (
272                fret_ui::element::PressableProps {
273                    layout: LayoutStyle::default(),
274                    enabled: true,
275                    focusable: false,
276                    ..Default::default()
277                },
278                Vec::new(),
279            )
280        },
281    )
282    .id
283}
284
285/// Returns the stable semantics element id for a navigation-menu viewport content.
286///
287/// This mirrors Radix `NavigationMenuTrigger` / `NavigationMenuContent` behavior where the trigger
288/// advertises a `controls` relationship (`aria-controls`) to the content element derived from the
289/// root + `value`.
290///
291/// Callers should use this root-name-scoped helper rather than trying to capture the mounted
292/// content id from the overlay subtree: triggers need stable ids even while the viewport is not
293/// mounted yet.
294pub fn navigation_menu_viewport_content_semantics_id<H: UiHost>(
295    cx: &mut ElementContext<'_, H>,
296    root_id: GlobalElementId,
297    overlay_root_name: &str,
298    value: &str,
299) -> GlobalElementId {
300    let inherited = portal_inherited::PortalInherited::capture(cx);
301    portal_inherited::with_root_name_inheriting(cx, overlay_root_name, inherited, |cx| {
302        navigation_menu_viewport_content_semantics_id_in_scope::<H>(cx, root_id, value)
303    })
304}
305
306/// Builds the viewport content wrapper using a stable call path keyed by `value`.
307///
308/// Use this instead of calling `ElementContext::pressable_with_id_props` directly when you need a
309/// deterministic content element id (e.g. for trigger `aria-controls` relationships).
310pub fn navigation_menu_viewport_content_pressable_with_id_props<H: UiHost>(
311    cx: &mut ElementContext<'_, H>,
312    root_id: GlobalElementId,
313    value: &str,
314    f: impl FnOnce(
315        &mut ElementContext<'_, H>,
316        fret_ui::element::PressableState,
317        GlobalElementId,
318    ) -> (fret_ui::element::PressableProps, Vec<AnyElement>),
319) -> AnyElement {
320    let value: Arc<str> = Arc::from(value);
321    cx.keyed(value.as_ref(), |cx| {
322        let value_for_registry = value.clone();
323        cx.pressable_with_id_props(move |cx, st, id| {
324            let (props, children) = f(cx, st, id);
325
326            if let Some(target) = find_first_focus_target(&children) {
327                let registry = navigation_menu_entry_focus_registry(cx, root_id);
328                let mut st = registry.lock().unwrap_or_else(|e| e.into_inner());
329                st.first_link_ids
330                    .entry(value_for_registry.clone())
331                    .or_insert(target);
332            }
333
334            (props, children)
335        })
336    })
337}
338
339#[derive(Default)]
340struct ViewportPresentSelectionState {
341    last_present_selected: Option<Arc<str>>,
342}
343
344/// Returns a selection value that is stable while a viewport overlay is present.
345///
346/// Radix keeps the last selected content mounted while closing so that the viewport can animate
347/// out without "snapping" to empty. Recipes pass `present=true` while the viewport overlay remains
348/// mounted (e.g. during close presence animations).
349pub fn navigation_menu_viewport_selected_value<H: UiHost>(
350    cx: &mut ElementContext<'_, H>,
351    root_id: GlobalElementId,
352    selected: Option<Arc<str>>,
353    present: bool,
354) -> Option<Arc<str>> {
355    cx.state_for(root_id, ViewportPresentSelectionState::default, |st| {
356        if selected.is_some() {
357            st.last_present_selected = selected.clone();
358            return selected;
359        }
360
361        if present {
362            return st.last_present_selected.clone();
363        }
364
365        None
366    })
367}
368
369#[derive(Default)]
370struct ViewportSizeRegistry {
371    sizes: HashMap<Arc<str>, Size>,
372    last_size: Option<Size>,
373}
374
375/// Registers the last measured viewport size for a given value.
376///
377/// This is a portable replacement for Radix's viewport CSS vars
378/// `--radix-navigation-menu-viewport-{width,height}`: recipes can read these values and animate
379/// their own overlay/layout policies accordingly.
380pub fn navigation_menu_register_viewport_size<H: UiHost>(
381    cx: &mut ElementContext<'_, H>,
382    root_id: GlobalElementId,
383    value: Arc<str>,
384    size: Size,
385) {
386    cx.state_for(root_id, ViewportSizeRegistry::default, |st| {
387        st.sizes.insert(value, size);
388        st.last_size = Some(size);
389    });
390}
391
392fn lerp_px(a: fret_core::Px, b: fret_core::Px, t: f32) -> fret_core::Px {
393    let t = t.clamp(0.0, 1.0);
394    fret_core::Px(a.0 + (b.0 - a.0) * t)
395}
396
397fn lerp_size(a: Size, b: Size, t: f32) -> Size {
398    Size::new(lerp_px(a.width, b.width, t), lerp_px(a.height, b.height, t))
399}
400
401#[derive(Debug, Clone, Copy, PartialEq)]
402pub struct NavigationMenuViewportSizeOutput {
403    pub size: Size,
404    pub from_size: Option<Size>,
405    pub to_size: Option<Size>,
406    pub progress: f32,
407    pub animating: bool,
408}
409
410impl Default for NavigationMenuViewportSizeOutput {
411    fn default() -> Self {
412        Self {
413            size: Size::default(),
414            from_size: None,
415            to_size: None,
416            progress: 1.0,
417            animating: false,
418        }
419    }
420}
421
422/// Returns the current viewport size, interpolating between the previous and next content sizes
423/// when switching values.
424///
425/// This models Radix's viewport sizing behavior (CSS vars + CSS transitions) in a recipe-friendly
426/// way: it exposes a single `Size` that can be fed into layout solvers or animated wrapper panels.
427pub fn navigation_menu_viewport_size_for_transition<H: UiHost>(
428    cx: &mut ElementContext<'_, H>,
429    root_id: GlobalElementId,
430    selected: Option<Arc<str>>,
431    values: &[Arc<str>],
432    transition: NavigationMenuContentTransitionOutput,
433    fallback: Size,
434) -> NavigationMenuViewportSizeOutput {
435    let (active_size, last_size, from_size, to_size) =
436        cx.state_for(root_id, ViewportSizeRegistry::default, |st| {
437            let active_size = selected
438                .as_ref()
439                .and_then(|v| st.sizes.get(v).copied())
440                .or(st.last_size)
441                .unwrap_or(fallback);
442
443            let from_size = transition
444                .from_idx
445                .and_then(|idx| values.get(idx))
446                .and_then(|v| st.sizes.get(v).copied());
447            let to_size = transition
448                .to_idx
449                .and_then(|idx| values.get(idx))
450                .and_then(|v| st.sizes.get(v).copied());
451
452            (active_size, st.last_size, from_size, to_size)
453        });
454
455    if !transition.switching {
456        return NavigationMenuViewportSizeOutput {
457            size: active_size,
458            from_size: None,
459            to_size: None,
460            progress: 1.0,
461            animating: false,
462        };
463    }
464
465    let Some(from_idx) = transition.from_idx else {
466        return NavigationMenuViewportSizeOutput {
467            size: active_size,
468            from_size: None,
469            to_size: None,
470            progress: 1.0,
471            animating: false,
472        };
473    };
474    let Some(to_idx) = transition.to_idx else {
475        return NavigationMenuViewportSizeOutput {
476            size: active_size,
477            from_size: None,
478            to_size: None,
479            progress: 1.0,
480            animating: false,
481        };
482    };
483    if from_idx == to_idx {
484        return NavigationMenuViewportSizeOutput {
485            size: active_size,
486            from_size: None,
487            to_size: None,
488            progress: 1.0,
489            animating: false,
490        };
491    }
492
493    let from_size = from_size.or(to_size).or(last_size).unwrap_or(fallback);
494    let to_size = to_size
495        .or(Some(from_size))
496        .or(last_size)
497        .unwrap_or(fallback);
498
499    let progress = transition.progress.clamp(0.0, 1.0);
500    let size = if transition.animating {
501        lerp_size(from_size, to_size, progress)
502    } else {
503        to_size
504    };
505
506    NavigationMenuViewportSizeOutput {
507        size,
508        from_size: Some(from_size),
509        to_size: Some(to_size),
510        progress,
511        animating: transition.animating,
512    }
513}
514
515/// Computes the indicator rect aligned to the currently active trigger and the placed viewport.
516///
517/// shadcn/ui renders the indicator as a rotated square (diamond) that sits between the trigger row
518/// and the viewport panel. Radix computes indicator offset/size via DOM measurement; in Fret we use
519/// geometry instead (anchor + placed viewport rects).
520pub fn navigation_menu_indicator_rect(
521    anchor: Rect,
522    viewport_rect: Rect,
523    side: Side,
524    indicator_thickness: Px,
525) -> Rect {
526    let thickness = indicator_thickness.0.max(0.0);
527
528    match side {
529        // Match Radix/shadcn behavior:
530        // - width tracks the active trigger width,
531        // - offset tracks the active trigger edge aligned to the viewport panel,
532        // - thickness fills the gap between trigger row and viewport panel.
533        Side::Bottom => Rect::new(
534            Point::new(anchor.origin.x, Px(viewport_rect.origin.y.0 - thickness)),
535            Size::new(anchor.size.width, Px(thickness)),
536        ),
537        Side::Top => Rect::new(
538            Point::new(
539                anchor.origin.x,
540                Px(viewport_rect.origin.y.0 + viewport_rect.size.height.0),
541            ),
542            Size::new(anchor.size.width, Px(thickness)),
543        ),
544        // Vertical orientation (rare in shadcn skins): treat thickness as the gutter width and
545        // track the active trigger height.
546        Side::Right => Rect::new(
547            Point::new(Px(viewport_rect.origin.x.0 - thickness), anchor.origin.y),
548            Size::new(Px(thickness), anchor.size.height),
549        ),
550        Side::Left => Rect::new(
551            Point::new(
552                Px(viewport_rect.origin.x.0 + viewport_rect.size.width.0),
553                anchor.origin.y,
554            ),
555            Size::new(Px(thickness), anchor.size.height),
556        ),
557    }
558}
559
560#[derive(Debug, Clone, Copy, PartialEq)]
561pub struct NavigationMenuViewportOverlayLayout {
562    pub anchor: Rect,
563    pub placed: Rect,
564    pub side: Side,
565    pub transform_origin: Point,
566    pub indicator_rect: Rect,
567}
568
569#[derive(Debug, Clone, Copy, PartialEq)]
570pub struct NavigationMenuViewportOverlayRequestArgs {
571    pub window_margin: Px,
572    pub placement: popper::PopperContentPlacement,
573    /// Optional override for the anchor element used to place the viewport panel.
574    ///
575    /// When `None`, placement uses the active trigger bounds.
576    pub placement_anchor_override: Option<GlobalElementId>,
577    pub content_size: Size,
578    pub indicator_size: Px,
579    /// When `true`, the viewport panel width matches the computed placement anchor width.
580    ///
581    /// This matches the upstream shadcn/ui mobile behavior (`w-full` on the viewport panel).
582    pub width_tracks_anchor: bool,
583}
584
585#[derive(Debug)]
586pub struct NavigationMenuViewportOverlayRenderOutput {
587    pub opacity: f32,
588    pub transform: Transform2D,
589    pub children: Vec<AnyElement>,
590}
591
592/// Requests a dismissible popover overlay for a navigation menu viewport/indicator pair.
593///
594/// This is a policy helper: it computes popper placement from the active trigger id, builds an
595/// overlay root name, and submits a `dismissible_popover` request. The caller provides the visual
596/// children and animation parameters (opacity/transform) so skins can remain in recipe layers.
597pub fn navigation_menu_request_viewport_overlay<H: UiHost>(
598    cx: &mut ElementContext<'_, H>,
599    root_id: GlobalElementId,
600    cfg: NavigationMenuConfig,
601    value_model: Model<Option<Arc<str>>>,
602    open_model: Model<bool>,
603    presence: OverlayPresence,
604    selected_value: Option<&str>,
605    args: NavigationMenuViewportOverlayRequestArgs,
606    on_pointer_move: Option<OnDismissiblePointerMove>,
607    render: impl FnOnce(
608        &mut ElementContext<'_, H>,
609        NavigationMenuViewportOverlayLayout,
610    ) -> NavigationMenuViewportOverlayRenderOutput,
611) -> Option<NavigationMenuViewportOverlayLayout> {
612    if !presence.present {
613        return None;
614    }
615
616    let overlay_root_name = OverlayController::popover_root_name(root_id);
617    let mut computed_layout: Option<NavigationMenuViewportOverlayLayout> = None;
618    let root_state: Arc<Mutex<NavigationMenuRootState>> = cx.state_for(
619        root_id,
620        || Arc::new(Mutex::new(NavigationMenuRootState::default())),
621        |s| s.clone(),
622    );
623    let trigger_states: Arc<Mutex<TriggerStateRegistry>> = cx.state_for(
624        root_id,
625        || Arc::new(Mutex::new(TriggerStateRegistry::default())),
626        |s| s.clone(),
627    );
628
629    let inherited = portal_inherited::PortalInherited::capture(cx);
630    let overlay_children = portal_inherited::with_root_name_inheriting(
631        cx,
632        &overlay_root_name,
633        inherited,
634        |cx| {
635            let Some(value) = selected_value else {
636                return Vec::new();
637            };
638            let trigger_anchor_id = navigation_menu_trigger_id(cx, root_id, value);
639            let trigger_anchor = trigger_anchor_id.and_then(|id| {
640                cx.last_visual_bounds_for_element(id)
641                    .or_else(|| cx.last_bounds_for_element(id))
642            });
643            let Some(trigger_anchor) = trigger_anchor else {
644                return Vec::new();
645            };
646
647            let placement_anchor = args
648                .placement_anchor_override
649                .and_then(|id| {
650                    cx.last_visual_bounds_for_element(id)
651                        .or_else(|| cx.last_bounds_for_element(id))
652                })
653                .map(|override_anchor| match args.placement.side {
654                    Side::Top | Side::Bottom => Rect::new(
655                        Point::new(override_anchor.origin.x, trigger_anchor.origin.y),
656                        Size::new(override_anchor.size.width, trigger_anchor.size.height),
657                    ),
658                    Side::Left | Side::Right => Rect::new(
659                        Point::new(trigger_anchor.origin.x, override_anchor.origin.y),
660                        Size::new(trigger_anchor.size.width, override_anchor.size.height),
661                    ),
662                })
663                .unwrap_or(trigger_anchor);
664
665            let content_size = if args.width_tracks_anchor {
666                Size::new(placement_anchor.size.width, args.content_size.height)
667            } else {
668                args.content_size
669            };
670
671            if std::env::var("FRET_DEBUG_NAV_MENU_OVERLAY").ok().as_deref() == Some("1") {
672                eprintln!(
673                    "nav-menu overlay root={:?} selected={:?} trigger_anchor={:?} override={:?} placement_anchor={:?} content_size={:?} width_tracks_anchor={}",
674                    root_id,
675                    selected_value,
676                    trigger_anchor,
677                    args.placement_anchor_override
678                        .and_then(|id| cx.last_bounds_for_element(id)),
679                    placement_anchor,
680                    content_size,
681                    args.width_tracks_anchor
682                );
683            }
684
685            let outer = overlay::outer_bounds_with_window_margin_for_environment(
686                cx,
687                fret_ui::Invalidation::Layout,
688                args.window_margin,
689            );
690            let popper_layout = popper::popper_content_layout_unclamped(
691                outer,
692                placement_anchor,
693                content_size,
694                args.placement,
695            );
696            let placed = popper_layout.rect;
697
698            if std::env::var("FRET_DEBUG_NAV_MENU_OVERLAY").ok().as_deref() == Some("1") {
699                eprintln!(
700                    "nav-menu overlay outer={:?} window_margin={:?} placed={:?}",
701                    outer, args.window_margin, placed
702                );
703            }
704
705            let transform_origin =
706                popper::popper_content_transform_origin(&popper_layout, placement_anchor, None);
707            let indicator_rect = navigation_menu_indicator_rect(
708                trigger_anchor,
709                placed,
710                popper_layout.side,
711                args.indicator_size,
712            );
713
714            let layout = NavigationMenuViewportOverlayLayout {
715                anchor: trigger_anchor,
716                placed,
717                side: popper_layout.side,
718                transform_origin,
719                indicator_rect,
720            };
721            computed_layout = Some(layout);
722
723            let out = render(cx, layout);
724
725            let overlay_content =
726                crate::declarative::overlay_motion::wrap_opacity_and_render_transform(
727                    cx,
728                    out.opacity,
729                    out.transform,
730                    out.children,
731                );
732
733            vec![overlay_content]
734        },
735    );
736
737    let open_model_for_request = open_model.clone();
738    let mut request = OverlayRequest::dismissible_popover(
739        root_id,
740        root_id,
741        open_model_for_request,
742        presence,
743        overlay_children,
744    );
745    request.root_name = Some(overlay_root_name);
746    request.dismissible_on_pointer_move = on_pointer_move;
747    let on_dismiss_request: fret_ui::action::OnDismissRequest = Arc::new({
748        let value_model = value_model.clone();
749        let root_state = root_state.clone();
750        let trigger_states = trigger_states.clone();
751        move |host: &mut dyn fret_ui::action::UiActionHost,
752              action_cx: fret_ui::action::ActionCx,
753              req: &mut fret_ui::action::DismissRequestCx| {
754            if req.default_prevented() {
755                return;
756            }
757
758            if req.reason == fret_ui::action::DismissReason::Escape {
759                let selected = host
760                    .models_mut()
761                    .read(&value_model, |v| v.clone())
762                    .ok()
763                    .flatten();
764                if let Some(value) = selected {
765                    let mut states = trigger_states.lock().unwrap_or_else(|e| e.into_inner());
766                    let entry = states.states.entry(value).or_default();
767                    entry.was_escape_close = true;
768                    entry.was_click_close = false;
769                    entry.has_pointer_move_opened = false;
770                }
771            }
772
773            let mut st = root_state.lock().unwrap_or_else(|e| e.into_inner());
774            st.on_item_dismiss(host, action_cx, &value_model, cfg);
775        }
776    });
777    let policy = crate::primitives::popover::PopoverCloseAutoFocusGuardPolicy::for_variant(
778        crate::primitives::popover::PopoverVariant::NonModal,
779        false,
780    );
781    let (on_dismiss_request, on_close_auto_focus) =
782        crate::primitives::popover::popover_close_auto_focus_guard_hooks(
783            cx,
784            policy,
785            open_model.clone(),
786            Some(on_dismiss_request),
787            None,
788        );
789    request.dismissible_on_dismiss_request = on_dismiss_request;
790    // Radix `NavigationMenu` keeps focus on the trigger when opening the viewport (it does not
791    // auto-focus the content like a modal/menu). Prevent the default overlay autofocus outcome so
792    // click-open does not focus-through into the first link.
793    request.on_open_auto_focus = Some(Arc::new(
794        |_host: &mut dyn fret_ui::action::UiFocusActionHost,
795         _action_cx: fret_ui::action::ActionCx,
796         req: &mut fret_ui::action::AutoFocusRequestCx| {
797            req.prevent_default();
798        },
799    ));
800    request.on_close_auto_focus = on_close_auto_focus;
801    OverlayController::request(cx, request);
802
803    computed_layout
804}
805
806/// A composable, Radix-shaped navigation-menu configuration surface.
807///
808/// This mirrors Radix's exported part names (`Root`, `List`, `Item`, `Trigger`, `Content`, `Link`,
809/// `Indicator`, `Viewport`) but models outcomes rather than DOM APIs.
810#[derive(Debug, Clone)]
811pub struct NavigationMenuRoot {
812    model: Model<Option<Arc<str>>>,
813    config: NavigationMenuConfig,
814    disabled: bool,
815}
816
817impl NavigationMenuRoot {
818    pub fn new(model: Model<Option<Arc<str>>>) -> Self {
819        Self {
820            model,
821            config: NavigationMenuConfig::default(),
822            disabled: false,
823        }
824    }
825
826    /// Creates a root with a controlled/uncontrolled selection model (Radix `value` /
827    /// `defaultValue`).
828    pub fn new_controllable<H: UiHost>(
829        cx: &mut ElementContext<'_, H>,
830        controlled: Option<Model<Option<Arc<str>>>>,
831        default_value: impl FnOnce() -> Option<Arc<str>>,
832    ) -> Self {
833        let model = navigation_menu_use_value_model(cx, controlled, default_value).model();
834        Self::new(model)
835    }
836
837    pub fn model(&self) -> Model<Option<Arc<str>>> {
838        self.model.clone()
839    }
840
841    pub fn config(mut self, config: NavigationMenuConfig) -> Self {
842        self.config = config;
843        self
844    }
845
846    pub fn disabled(mut self, disabled: bool) -> Self {
847        self.disabled = disabled;
848        self
849    }
850
851    pub fn context<H: UiHost>(
852        &self,
853        cx: &mut ElementContext<'_, H>,
854        root_id: GlobalElementId,
855    ) -> NavigationMenuContext {
856        let root_state: Arc<Mutex<NavigationMenuRootState>> = cx.state_for(
857            root_id,
858            || Arc::new(Mutex::new(NavigationMenuRootState::default())),
859            |s| s.clone(),
860        );
861        {
862            let entry_focus_registry = navigation_menu_entry_focus_registry(cx, root_id);
863            let mut st = entry_focus_registry
864                .lock()
865                .unwrap_or_else(|e| e.into_inner());
866            if st.frame_id != Some(cx.frame_id) {
867                st.frame_id = Some(cx.frame_id);
868                st.first_link_ids.clear();
869            }
870        }
871
872        let value_model_for_timer = self.model.clone();
873        let root_state_for_timer = root_state.clone();
874        let cfg = self.config;
875        cx.timer_on_timer_for(
876            root_id,
877            Arc::new(move |host, action_cx, token| {
878                let mut st = root_state_for_timer
879                    .lock()
880                    .unwrap_or_else(|e| e.into_inner());
881                st.on_timer(host, action_cx, token, &value_model_for_timer, cfg)
882            }),
883        );
884
885        NavigationMenuContext {
886            root_id,
887            model: self.model.clone(),
888            config: self.config,
889            disabled: self.disabled,
890            root_state,
891        }
892    }
893
894    pub fn trigger(&self, value: impl Into<Arc<str>>) -> NavigationMenuTrigger {
895        NavigationMenuTrigger::new(value)
896    }
897
898    pub fn content(&self, value: impl Into<Arc<str>>) -> NavigationMenuContent {
899        NavigationMenuContent::new(value)
900    }
901
902    pub fn link(&self) -> NavigationMenuLink {
903        NavigationMenuLink::new()
904    }
905}
906
907#[derive(Clone)]
908pub struct NavigationMenuContext {
909    pub root_id: GlobalElementId,
910    pub model: Model<Option<Arc<str>>>,
911    pub config: NavigationMenuConfig,
912    pub disabled: bool,
913    pub root_state: Arc<Mutex<NavigationMenuRootState>>,
914}
915
916impl NavigationMenuContext {
917    pub fn selected<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Option<Arc<str>> {
918        cx.watch_model(&self.model).layout().cloned().flatten()
919    }
920}
921
922#[derive(Debug, Clone)]
923pub struct NavigationMenuTrigger {
924    value: Arc<str>,
925    label: Option<Arc<str>>,
926    disabled: bool,
927}
928
929impl NavigationMenuTrigger {
930    pub fn new(value: impl Into<Arc<str>>) -> Self {
931        Self {
932            value: value.into(),
933            label: None,
934            disabled: false,
935        }
936    }
937
938    pub fn label(mut self, label: impl Into<Arc<str>>) -> Self {
939        self.label = Some(label.into());
940        self
941    }
942
943    pub fn disabled(mut self, disabled: bool) -> Self {
944        self.disabled = disabled;
945        self
946    }
947
948    /// Renders a trigger subtree wiring Radix-like hover and open/close behavior.
949    ///
950    /// This helper is skin-free: pass `PressableProps` and render children in `f`.
951    #[track_caller]
952    pub fn into_element<H: UiHost>(
953        self,
954        cx: &mut ElementContext<'_, H>,
955        ctx: &NavigationMenuContext,
956        mut pressable: fret_ui::element::PressableProps,
957        pointer_region: fret_ui::element::PointerRegionProps,
958        f: impl FnOnce(
959            &mut ElementContext<'_, H>,
960            fret_ui::element::PressableState,
961            bool,
962        ) -> Vec<fret_ui::element::AnyElement>,
963    ) -> fret_ui::element::AnyElement {
964        let value_model = ctx.model.clone();
965        let item_value = self.value.clone();
966        let disabled = ctx.disabled || self.disabled;
967        let cfg = ctx.config;
968        let root_id = ctx.root_id;
969        let root_state = ctx.root_state.clone();
970        let entry_focus_registry = navigation_menu_entry_focus_registry(cx, root_id);
971        let trigger_states: Arc<Mutex<TriggerStateRegistry>> = cx.state_for(
972            root_id,
973            || Arc::new(Mutex::new(TriggerStateRegistry::default())),
974            |s| s.clone(),
975        );
976
977        let is_open = cx
978            .watch_model(&value_model)
979            .layout()
980            .cloned()
981            .flatten()
982            .as_deref()
983            .is_some_and(|v| v == item_value.as_ref());
984
985        pressable.enabled = !disabled;
986        pressable.focusable = !disabled;
987
988        if pressable.a11y.role.is_none() {
989            pressable.a11y.role = Some(fret_core::SemanticsRole::Button);
990        }
991        if pressable.a11y.label.is_none() {
992            pressable.a11y.label = self.label.clone();
993        }
994        pressable.a11y.expanded = Some(is_open);
995        if pressable.a11y.controls_element.is_none() {
996            let overlay_root_name = OverlayController::popover_root_name(root_id);
997            let content_id = navigation_menu_viewport_content_semantics_id::<H>(
998                cx,
999                root_id,
1000                overlay_root_name.as_str(),
1001                item_value.as_ref(),
1002            );
1003            pressable.a11y.controls_element = Some(content_id.0);
1004        }
1005
1006        cx.pointer_region(pointer_region, move |cx| {
1007            if !disabled {
1008                let trigger_states_for_pointer_move = trigger_states.clone();
1009                let root_state_for_pointer_move = root_state.clone();
1010                let value_for_pointer_move = value_model.clone();
1011                let item_value_for_pointer_move = item_value.clone();
1012                cx.pointer_region_on_pointer_move(Arc::new(move |host, action_cx, mv| {
1013                    let mut states = trigger_states_for_pointer_move
1014                        .lock()
1015                        .unwrap_or_else(|e| e.into_inner());
1016                    let st = *states
1017                        .states
1018                        .get(item_value_for_pointer_move.as_ref())
1019                        .unwrap_or(&NavigationMenuTriggerState::default());
1020                    match navigation_menu_trigger_pointer_move_action(mv.pointer_type, disabled, st)
1021                    {
1022                        NavigationMenuTriggerPointerMoveAction::Ignore => false,
1023                        NavigationMenuTriggerPointerMoveAction::Open => {
1024                            let mut root = root_state_for_pointer_move
1025                                .lock()
1026                                .unwrap_or_else(|e| e.into_inner());
1027                            root.on_trigger_enter(
1028                                host,
1029                                action_cx,
1030                                &value_for_pointer_move,
1031                                item_value_for_pointer_move.clone(),
1032                                cfg,
1033                            );
1034                            states.states.insert(
1035                                item_value_for_pointer_move.clone(),
1036                                NavigationMenuTriggerState {
1037                                    has_pointer_move_opened: true,
1038                                    was_click_close: false,
1039                                    was_escape_close: false,
1040                                },
1041                            );
1042                            false
1043                        }
1044                    }
1045                }));
1046            }
1047
1048            let item_value_for_registry = item_value.clone();
1049            vec![cx.pressable_with_id(pressable, move |cx, st, trigger_id| {
1050                navigation_menu_register_trigger_id(
1051                    cx,
1052                    root_id,
1053                    item_value_for_registry.clone(),
1054                    trigger_id,
1055                );
1056
1057                if !disabled {
1058                    use fret_core::KeyCode;
1059
1060                    let element = trigger_id;
1061                    let root_state_for_escape = root_state.clone();
1062                    let value_for_escape = value_model.clone();
1063                    let trigger_states_for_escape = trigger_states.clone();
1064                    cx.key_on_key_down_for(
1065                        element,
1066                        Arc::new(move |host, action_cx, it| {
1067                            if it.repeat || it.key != KeyCode::Escape {
1068                                return false;
1069                            }
1070
1071                            let selected = host
1072                                .models_mut()
1073                                .read(&value_for_escape, |v| v.clone())
1074                                .ok()
1075                                .flatten();
1076                            let Some(selected) = selected else {
1077                                return false;
1078                            };
1079
1080                            let mut root = root_state_for_escape
1081                                .lock()
1082                                .unwrap_or_else(|e| e.into_inner());
1083                            root.on_item_dismiss(host, action_cx, &value_for_escape, cfg);
1084
1085                            let mut states = trigger_states_for_escape
1086                                .lock()
1087                                .unwrap_or_else(|e| e.into_inner());
1088                            let entry = states.states.entry(selected).or_default();
1089                            entry.was_escape_close = true;
1090                            entry.was_click_close = false;
1091                            entry.has_pointer_move_opened = false;
1092
1093                            true
1094                        }),
1095                    );
1096
1097                    let item_value_for_entry_key = item_value_for_registry.clone();
1098                    let value_for_entry_key = value_model.clone();
1099                    let entry_focus_registry_for_entry_key = entry_focus_registry.clone();
1100                    cx.key_add_on_key_down_for(
1101                        element,
1102                        Arc::new(move |host, action_cx, it| {
1103                            if it.repeat {
1104                                return false;
1105                            }
1106
1107                            if it.key != KeyCode::ArrowDown {
1108                                return false;
1109                            }
1110
1111                            let selected = host
1112                                .models_mut()
1113                                .read(&value_for_entry_key, |v| v.clone())
1114                                .ok()
1115                                .flatten();
1116                            let open = selected
1117                                .as_ref()
1118                                .is_some_and(|v| v.as_ref() == item_value_for_entry_key.as_ref());
1119                            if !open {
1120                                return false;
1121                            }
1122
1123                            let target = entry_focus_registry_for_entry_key
1124                                .lock()
1125                                .unwrap_or_else(|e| e.into_inner())
1126                                .first_link_ids
1127                                .get(item_value_for_entry_key.as_ref())
1128                                .copied();
1129                            if let Some(target) = target {
1130                                host.request_focus(target);
1131                            } else {
1132                                host.dispatch_command(
1133                                    Some(action_cx.window),
1134                                    CommandId::from("focus.next"),
1135                                );
1136                            }
1137                            host.request_redraw(action_cx.window);
1138                            true
1139                        }),
1140                    );
1141
1142                    let item_value_for_focus_next = item_value_for_registry.clone();
1143                    let value_for_focus_next = value_model.clone();
1144                    let entry_focus_registry_for_focus_next = entry_focus_registry.clone();
1145                    cx.command_add_on_command_for(
1146                        element,
1147                        Arc::new(move |host, action_cx, command| {
1148                            if command.as_str() != "focus.next" {
1149                                return false;
1150                            }
1151
1152                            let selected = host
1153                                .models_mut()
1154                                .read(&value_for_focus_next, |v| v.clone())
1155                                .ok()
1156                                .flatten();
1157                            let open = selected
1158                                .as_ref()
1159                                .is_some_and(|v| v.as_ref() == item_value_for_focus_next.as_ref());
1160                            if !open {
1161                                return false;
1162                            }
1163
1164                            let target = entry_focus_registry_for_focus_next
1165                                .lock()
1166                                .unwrap_or_else(|e| e.into_inner())
1167                                .first_link_ids
1168                                .get(item_value_for_focus_next.as_ref())
1169                                .copied();
1170                            let Some(target) = target else {
1171                                return false;
1172                            };
1173                            host.request_focus(target);
1174                            host.request_redraw(action_cx.window);
1175                            true
1176                        }),
1177                    );
1178
1179                    let root_state_for_activate = root_state.clone();
1180                    let value_for_activate = value_model.clone();
1181                    let trigger_states_for_activate = trigger_states.clone();
1182                    let item_value_for_activate = item_value.clone();
1183                    cx.pressable_add_on_activate(crate::on_activate(
1184                        move |host, action_cx, _reason| {
1185                            let mut root = root_state_for_activate
1186                                .lock()
1187                                .unwrap_or_else(|e| e.into_inner());
1188                            root.on_item_select(
1189                                host,
1190                                action_cx,
1191                                &value_for_activate,
1192                                item_value_for_activate.clone(),
1193                                cfg,
1194                            );
1195
1196                            let now_open = host
1197                                .models_mut()
1198                                .read(&value_for_activate, |v| v.clone())
1199                                .ok()
1200                                .flatten()
1201                                .is_some_and(|v| v.as_ref() == item_value_for_activate.as_ref());
1202
1203                            let mut states = trigger_states_for_activate
1204                                .lock()
1205                                .unwrap_or_else(|e| e.into_inner());
1206                            let entry = states
1207                                .states
1208                                .entry(item_value_for_activate.clone())
1209                                .or_default();
1210                            entry.was_click_close = !now_open;
1211                            if now_open {
1212                                entry.was_escape_close = false;
1213                            }
1214                            entry.has_pointer_move_opened = false;
1215                        },
1216                    ));
1217
1218                    let trigger_states_for_hover = trigger_states.clone();
1219                    let root_state_for_hover = root_state.clone();
1220                    let value_for_trigger = value_model.clone();
1221                    let item_value_for_hover = item_value.clone();
1222                    cx.pressable_on_hover_change(Arc::new(move |host, action_cx, hovered| {
1223                        if hovered {
1224                            // Radix clears the escape/click close gates on pointer enter so a
1225                            // subsequent pointer move can reopen the menu. Mirror that behavior
1226                            // so hover-open remains reliable across subtree rebuilds (e.g. when a
1227                            // recipe toggles breakpoints or query sources).
1228                            let mut states = trigger_states_for_hover
1229                                .lock()
1230                                .unwrap_or_else(|e| e.into_inner());
1231                            if let Some(entry) =
1232                                states.states.get_mut(item_value_for_hover.as_ref())
1233                            {
1234                                entry.was_escape_close = false;
1235                                entry.was_click_close = false;
1236                                entry.has_pointer_move_opened = false;
1237                            }
1238                            return;
1239                        }
1240                        let mut root = root_state_for_hover
1241                            .lock()
1242                            .unwrap_or_else(|e| e.into_inner());
1243                        root.on_trigger_leave(host, action_cx, &value_for_trigger, cfg);
1244                        let mut states = trigger_states_for_hover
1245                            .lock()
1246                            .unwrap_or_else(|e| e.into_inner());
1247                        states.states.remove(item_value_for_hover.as_ref());
1248                    }));
1249                }
1250
1251                f(cx, st, is_open)
1252            })]
1253        })
1254    }
1255}
1256
1257#[derive(Debug, Clone)]
1258pub struct NavigationMenuContent {
1259    value: Arc<str>,
1260    force_mount: bool,
1261}
1262
1263impl NavigationMenuContent {
1264    pub fn new(value: impl Into<Arc<str>>) -> Self {
1265        Self {
1266            value: value.into(),
1267            force_mount: false,
1268        }
1269    }
1270
1271    pub fn force_mount(mut self, force_mount: bool) -> Self {
1272        self.force_mount = force_mount;
1273        self
1274    }
1275
1276    #[track_caller]
1277    pub fn into_element<H: UiHost>(
1278        self,
1279        cx: &mut ElementContext<'_, H>,
1280        ctx: &NavigationMenuContext,
1281        f: impl FnOnce(&mut ElementContext<'_, H>) -> Vec<fret_ui::element::AnyElement>,
1282    ) -> Option<fret_ui::element::AnyElement> {
1283        let selected = ctx.selected(cx);
1284        let active = selected.as_deref() == Some(self.value.as_ref());
1285        if !active && !self.force_mount {
1286            return None;
1287        }
1288        let value = self.value.clone();
1289        let entry_focus_registry = navigation_menu_entry_focus_registry(cx, ctx.root_id);
1290        if self.force_mount {
1291            Some(cx.interactivity_gate(active, active, move |cx| {
1292                let children = f(cx);
1293                if active {
1294                    let first = find_first_focus_target(&children);
1295                    let mut st = entry_focus_registry
1296                        .lock()
1297                        .unwrap_or_else(|e| e.into_inner());
1298                    if let Some(first) = first {
1299                        st.first_link_ids.insert(value.clone(), first);
1300                    } else {
1301                        st.first_link_ids.remove(value.as_ref());
1302                    }
1303                }
1304                children
1305            }))
1306        } else {
1307            Some(cx.interactivity_gate(true, true, move |cx| {
1308                let children = f(cx);
1309                if active {
1310                    let first = find_first_focus_target(&children);
1311                    let mut st = entry_focus_registry
1312                        .lock()
1313                        .unwrap_or_else(|e| e.into_inner());
1314                    if let Some(first) = first {
1315                        st.first_link_ids.insert(value.clone(), first);
1316                    } else {
1317                        st.first_link_ids.remove(value.as_ref());
1318                    }
1319                }
1320                children
1321            }))
1322        }
1323    }
1324}
1325
1326#[derive(Debug, Clone)]
1327pub struct NavigationMenuLink {
1328    dismiss_on_select: bool,
1329    dismiss_on_ctrl_or_meta: bool,
1330}
1331
1332impl NavigationMenuLink {
1333    pub fn new() -> Self {
1334        Self {
1335            dismiss_on_select: true,
1336            dismiss_on_ctrl_or_meta: false,
1337        }
1338    }
1339
1340    pub fn dismiss_on_select(mut self, dismiss_on_select: bool) -> Self {
1341        self.dismiss_on_select = dismiss_on_select;
1342        self
1343    }
1344
1345    /// When `false` (default), link activation with Ctrl/Meta pressed does not dismiss the root.
1346    ///
1347    /// This matches Radix's `NavigationMenuLink`: modified clicks are treated like "open in new tab"
1348    /// and should not close the menu.
1349    pub fn dismiss_on_ctrl_or_meta(mut self, dismiss_on_ctrl_or_meta: bool) -> Self {
1350        self.dismiss_on_ctrl_or_meta = dismiss_on_ctrl_or_meta;
1351        self
1352    }
1353
1354    #[track_caller]
1355    pub fn into_element<H: UiHost>(
1356        self,
1357        cx: &mut ElementContext<'_, H>,
1358        ctx: &NavigationMenuContext,
1359        mut pressable: fret_ui::element::PressableProps,
1360        f: impl FnOnce(
1361            &mut ElementContext<'_, H>,
1362            fret_ui::element::PressableState,
1363        ) -> Vec<fret_ui::element::AnyElement>,
1364    ) -> fret_ui::element::AnyElement {
1365        #[derive(Default)]
1366        struct LinkModifierState {
1367            suppress_dismiss_for_next_activate: bool,
1368        }
1369
1370        let disabled = ctx.disabled;
1371        pressable.enabled = pressable.enabled && !disabled;
1372        pressable.focusable = pressable.focusable && !disabled;
1373
1374        let root_state = ctx.root_state.clone();
1375        let value_model = ctx.model.clone();
1376        let cfg = ctx.config;
1377        let dismiss = self.dismiss_on_select;
1378        let dismiss_on_ctrl_or_meta = self.dismiss_on_ctrl_or_meta;
1379        cx.pressable(pressable, move |cx, st| {
1380            if dismiss && !disabled {
1381                let modifier_state: Arc<Mutex<LinkModifierState>> = cx.state_for(
1382                    cx.root_id(),
1383                    || Arc::new(Mutex::new(LinkModifierState::default())),
1384                    |s| s.clone(),
1385                );
1386                let modifier_state_for_pointer = modifier_state.clone();
1387                cx.pressable_add_on_pointer_down(Arc::new(move |_host, _cx, down| {
1388                    use fret_ui::action::PressablePointerDownResult as R;
1389
1390                    let suppress = navigation_menu_link_suppresses_dismiss(
1391                        down.modifiers,
1392                        dismiss_on_ctrl_or_meta,
1393                    );
1394                    let mut st = modifier_state_for_pointer
1395                        .lock()
1396                        .unwrap_or_else(|e| e.into_inner());
1397                    st.suppress_dismiss_for_next_activate = suppress;
1398                    R::Continue
1399                }));
1400
1401                let root_state_for_dismiss = root_state.clone();
1402                let value_for_dismiss = value_model.clone();
1403                cx.pressable_add_on_activate(crate::on_activate(
1404                    move |host, action_cx, _reason| {
1405                        let mut st = modifier_state.lock().unwrap_or_else(|e| e.into_inner());
1406                        let suppress = st.suppress_dismiss_for_next_activate;
1407                        st.suppress_dismiss_for_next_activate = false;
1408                        if suppress {
1409                            return;
1410                        }
1411
1412                        let mut root = root_state_for_dismiss
1413                            .lock()
1414                            .unwrap_or_else(|e| e.into_inner());
1415                        root.on_item_dismiss(host, action_cx, &value_for_dismiss, cfg);
1416                    },
1417                ));
1418            }
1419            f(cx, st)
1420        })
1421    }
1422}
1423
1424fn navigation_menu_link_suppresses_dismiss(
1425    modifiers: Modifiers,
1426    dismiss_on_ctrl_or_meta: bool,
1427) -> bool {
1428    (modifiers.ctrl || modifiers.meta) && !dismiss_on_ctrl_or_meta
1429}
1430
1431fn cancel_timer(host: &mut dyn UiActionHost, token: &mut Option<TimerToken>) {
1432    if let Some(token) = token.take() {
1433        host.push_effect(Effect::CancelTimer { token });
1434    }
1435}
1436
1437fn arm_timer(
1438    host: &mut dyn UiActionHost,
1439    window: fret_core::AppWindowId,
1440    after: Duration,
1441    token_out: &mut Option<TimerToken>,
1442) -> TimerToken {
1443    cancel_timer(host, token_out);
1444    let token = host.next_timer_token();
1445    host.push_effect(Effect::SetTimer {
1446        window: Some(window),
1447        token,
1448        after,
1449        repeat: None,
1450    });
1451    *token_out = Some(token);
1452    token
1453}
1454
1455#[derive(Debug, Clone)]
1456pub struct NavigationMenuRootState {
1457    open_timer: Option<TimerToken>,
1458    close_timer: Option<TimerToken>,
1459    skip_delay_timer: Option<TimerToken>,
1460    pending_open_value: Option<Arc<str>>,
1461    is_open_delayed: bool,
1462}
1463
1464impl Default for NavigationMenuRootState {
1465    fn default() -> Self {
1466        Self {
1467            open_timer: None,
1468            close_timer: None,
1469            skip_delay_timer: None,
1470            pending_open_value: None,
1471            is_open_delayed: true,
1472        }
1473    }
1474}
1475
1476impl NavigationMenuRootState {
1477    pub fn is_open_delayed(&self) -> bool {
1478        self.is_open_delayed
1479    }
1480
1481    pub fn clear_timers(&mut self, host: &mut dyn UiActionHost) {
1482        cancel_timer(host, &mut self.open_timer);
1483        cancel_timer(host, &mut self.close_timer);
1484        cancel_timer(host, &mut self.skip_delay_timer);
1485        self.pending_open_value = None;
1486    }
1487
1488    fn note_opened(&mut self, host: &mut dyn UiActionHost, cfg: NavigationMenuConfig) {
1489        cancel_timer(host, &mut self.skip_delay_timer);
1490        // Radix only skips open delays when `skipDelayDuration > 0`.
1491        self.is_open_delayed = cfg.skip_delay_duration.is_zero();
1492    }
1493
1494    fn note_closed(
1495        &mut self,
1496        host: &mut dyn UiActionHost,
1497        window: fret_core::AppWindowId,
1498        cfg: NavigationMenuConfig,
1499    ) {
1500        cancel_timer(host, &mut self.skip_delay_timer);
1501        self.is_open_delayed = true;
1502        if cfg.skip_delay_duration.is_zero() {
1503            return;
1504        }
1505        // Mirror Radix: after `skipDelayDuration`, re-enable delayed opening.
1506        arm_timer(
1507            host,
1508            window,
1509            cfg.skip_delay_duration,
1510            &mut self.skip_delay_timer,
1511        );
1512        // While the timer is armed we keep `is_open_delayed=false` (immediate-open window).
1513        self.is_open_delayed = false;
1514    }
1515
1516    pub fn on_trigger_enter(
1517        &mut self,
1518        host: &mut dyn UiActionHost,
1519        acx: ActionCx,
1520        value_model: &Model<Option<Arc<str>>>,
1521        item_value: Arc<str>,
1522        cfg: NavigationMenuConfig,
1523    ) {
1524        cancel_timer(host, &mut self.open_timer);
1525
1526        let current = host
1527            .models_mut()
1528            .read(value_model, |v| v.clone())
1529            .ok()
1530            .flatten();
1531
1532        // Always cancel close when entering a trigger.
1533        cancel_timer(host, &mut self.close_timer);
1534
1535        if !self.is_open_delayed {
1536            let _ = host
1537                .models_mut()
1538                .update(value_model, |v| *v = Some(item_value.clone()));
1539            self.note_opened(host, cfg);
1540            host.request_redraw(acx.window);
1541            return;
1542        }
1543
1544        // Delayed open: if the item is already open, just clear the close timer (done above).
1545        if current.as_deref() == Some(item_value.as_ref()) {
1546            return;
1547        }
1548
1549        self.pending_open_value = Some(item_value);
1550        arm_timer(host, acx.window, cfg.delay_duration, &mut self.open_timer);
1551        host.request_redraw(acx.window);
1552    }
1553
1554    pub fn on_trigger_leave(
1555        &mut self,
1556        host: &mut dyn UiActionHost,
1557        acx: ActionCx,
1558        value_model: &Model<Option<Arc<str>>>,
1559        cfg: NavigationMenuConfig,
1560    ) {
1561        cancel_timer(host, &mut self.open_timer);
1562        self.pending_open_value = None;
1563        self.start_close_timer(host, acx, value_model, cfg);
1564    }
1565
1566    pub fn on_content_enter(&mut self, host: &mut dyn UiActionHost) {
1567        cancel_timer(host, &mut self.close_timer);
1568    }
1569
1570    pub fn on_content_leave(
1571        &mut self,
1572        host: &mut dyn UiActionHost,
1573        acx: ActionCx,
1574        value_model: &Model<Option<Arc<str>>>,
1575        cfg: NavigationMenuConfig,
1576    ) {
1577        self.start_close_timer(host, acx, value_model, cfg);
1578    }
1579
1580    fn start_close_timer(
1581        &mut self,
1582        host: &mut dyn UiActionHost,
1583        acx: ActionCx,
1584        value_model: &Model<Option<Arc<str>>>,
1585        cfg: NavigationMenuConfig,
1586    ) {
1587        if cfg.close_delay_duration.is_zero() {
1588            let _ = host.models_mut().update(value_model, |v| *v = None);
1589            self.note_closed(host, acx.window, cfg);
1590            host.request_redraw(acx.window);
1591            return;
1592        }
1593        arm_timer(
1594            host,
1595            acx.window,
1596            cfg.close_delay_duration,
1597            &mut self.close_timer,
1598        );
1599        host.request_redraw(acx.window);
1600    }
1601
1602    pub fn on_item_select(
1603        &mut self,
1604        host: &mut dyn UiActionHost,
1605        acx: ActionCx,
1606        value_model: &Model<Option<Arc<str>>>,
1607        item_value: Arc<str>,
1608        cfg: NavigationMenuConfig,
1609    ) {
1610        cancel_timer(host, &mut self.open_timer);
1611        cancel_timer(host, &mut self.close_timer);
1612        self.pending_open_value = None;
1613
1614        let current = host
1615            .models_mut()
1616            .read(value_model, |v| v.clone())
1617            .ok()
1618            .flatten();
1619        if current.as_deref() == Some(item_value.as_ref()) {
1620            let _ = host.models_mut().update(value_model, |v| *v = None);
1621            self.note_closed(host, acx.window, cfg);
1622        } else {
1623            let _ = host
1624                .models_mut()
1625                .update(value_model, |v| *v = Some(item_value.clone()));
1626            self.note_opened(host, cfg);
1627        }
1628
1629        host.request_redraw(acx.window);
1630    }
1631
1632    pub fn on_item_dismiss(
1633        &mut self,
1634        host: &mut dyn UiActionHost,
1635        acx: ActionCx,
1636        value_model: &Model<Option<Arc<str>>>,
1637        cfg: NavigationMenuConfig,
1638    ) {
1639        cancel_timer(host, &mut self.open_timer);
1640        cancel_timer(host, &mut self.close_timer);
1641        self.pending_open_value = None;
1642
1643        let _ = host.models_mut().update(value_model, |v| *v = None);
1644        self.note_closed(host, acx.window, cfg);
1645        host.request_redraw(acx.window);
1646    }
1647
1648    /// Handle a timer callback (open/close/skip-delay).
1649    ///
1650    /// Returns `true` when it updates state and a redraw should be requested by the caller.
1651    pub fn on_timer(
1652        &mut self,
1653        host: &mut dyn UiActionHost,
1654        acx: ActionCx,
1655        token: TimerToken,
1656        value_model: &Model<Option<Arc<str>>>,
1657        cfg: NavigationMenuConfig,
1658    ) -> bool {
1659        if self.open_timer == Some(token) {
1660            self.open_timer = None;
1661            let Some(value) = self.pending_open_value.take() else {
1662                return false;
1663            };
1664            cancel_timer(host, &mut self.close_timer);
1665            let _ = host.models_mut().update(value_model, |v| *v = Some(value));
1666            self.note_opened(host, cfg);
1667            host.request_redraw(acx.window);
1668            return true;
1669        }
1670
1671        if self.close_timer == Some(token) {
1672            self.close_timer = None;
1673            let _ = host.models_mut().update(value_model, |v| *v = None);
1674            self.note_closed(host, acx.window, cfg);
1675            host.request_redraw(acx.window);
1676            return true;
1677        }
1678
1679        if self.skip_delay_timer == Some(token) {
1680            self.skip_delay_timer = None;
1681            self.is_open_delayed = true;
1682            host.request_redraw(acx.window);
1683            return true;
1684        }
1685
1686        false
1687    }
1688}
1689
1690#[derive(Debug, Default, Clone, Copy)]
1691pub struct NavigationMenuTriggerState {
1692    pub has_pointer_move_opened: bool,
1693    pub was_click_close: bool,
1694    pub was_escape_close: bool,
1695}
1696
1697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1698pub enum NavigationMenuTriggerPointerMoveAction {
1699    Open,
1700    Ignore,
1701}
1702
1703/// Mirror Radix `NavigationMenuTrigger` hover-open gating.
1704pub fn navigation_menu_trigger_pointer_move_action(
1705    pointer_type: PointerType,
1706    disabled: bool,
1707    state: NavigationMenuTriggerState,
1708) -> NavigationMenuTriggerPointerMoveAction {
1709    match pointer_type {
1710        PointerType::Touch | PointerType::Pen => NavigationMenuTriggerPointerMoveAction::Ignore,
1711        PointerType::Mouse | PointerType::Unknown => {
1712            if disabled
1713                || state.was_click_close
1714                || state.was_escape_close
1715                || state.has_pointer_move_opened
1716            {
1717                NavigationMenuTriggerPointerMoveAction::Ignore
1718            } else {
1719                NavigationMenuTriggerPointerMoveAction::Open
1720            }
1721        }
1722    }
1723}
1724
1725/// Matches Radix `data-motion` values used by `NavigationMenuContent` when switching between
1726/// values.
1727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728pub enum NavigationMenuContentMotion {
1729    None,
1730    FromStart,
1731    FromEnd,
1732    ToStart,
1733    ToEnd,
1734}
1735
1736impl NavigationMenuContentMotion {
1737    pub fn as_str(self) -> &'static str {
1738        match self {
1739            NavigationMenuContentMotion::None => "none",
1740            NavigationMenuContentMotion::FromStart => "from-start",
1741            NavigationMenuContentMotion::FromEnd => "from-end",
1742            NavigationMenuContentMotion::ToStart => "to-start",
1743            NavigationMenuContentMotion::ToEnd => "to-end",
1744        }
1745    }
1746}
1747
1748#[derive(Debug, Clone, Copy, PartialEq)]
1749pub struct NavigationMenuContentTransitionOutput {
1750    pub from_idx: Option<usize>,
1751    pub to_idx: Option<usize>,
1752    pub switching: bool,
1753    /// Transition progress in `[0, 1]` where `0` is the beginning of the switch and `1` is the end.
1754    pub progress: f32,
1755    pub animating: bool,
1756    pub from_motion: NavigationMenuContentMotion,
1757    pub to_motion: NavigationMenuContentMotion,
1758}
1759
1760/// A convenience wrapper around [`NavigationMenuContentTransitionOutput`] for recipes that need to
1761/// render "from" + "to" layers during a value switch (shadcn-style `data-motion` transitions).
1762///
1763/// This models Radix's behavior of only animating the selected and the previously selected content,
1764/// avoiding unnecessary mounts and interrupted animations outside of that range.
1765#[derive(Debug, Clone, Copy, PartialEq)]
1766pub struct NavigationMenuContentSwitchOutput {
1767    pub from_idx: usize,
1768    pub to_idx: usize,
1769    pub progress: f32,
1770    pub animating: bool,
1771    pub forward: bool,
1772    pub from_motion: NavigationMenuContentMotion,
1773    pub to_motion: NavigationMenuContentMotion,
1774}
1775
1776/// Returns a switch output when the content transition is actively animating between two indices.
1777pub fn navigation_menu_content_switch(
1778    transition: NavigationMenuContentTransitionOutput,
1779) -> Option<NavigationMenuContentSwitchOutput> {
1780    if !transition.switching || !transition.animating {
1781        return None;
1782    }
1783    let (Some(from_idx), Some(to_idx)) = (transition.from_idx, transition.to_idx) else {
1784        return None;
1785    };
1786    if from_idx == to_idx {
1787        return None;
1788    }
1789    Some(NavigationMenuContentSwitchOutput {
1790        from_idx,
1791        to_idx,
1792        progress: transition.progress.clamp(0.0, 1.0),
1793        animating: transition.animating,
1794        forward: to_idx > from_idx,
1795        from_motion: transition.from_motion,
1796        to_motion: transition.to_motion,
1797    })
1798}
1799
1800impl Default for NavigationMenuContentTransitionOutput {
1801    fn default() -> Self {
1802        Self {
1803            from_idx: None,
1804            to_idx: None,
1805            switching: false,
1806            progress: 1.0,
1807            animating: false,
1808            from_motion: NavigationMenuContentMotion::None,
1809            to_motion: NavigationMenuContentMotion::None,
1810        }
1811    }
1812}
1813
1814fn content_motion(
1815    from_idx: usize,
1816    to_idx: usize,
1817) -> (NavigationMenuContentMotion, NavigationMenuContentMotion) {
1818    if from_idx == to_idx {
1819        return (
1820            NavigationMenuContentMotion::None,
1821            NavigationMenuContentMotion::None,
1822        );
1823    }
1824
1825    // Radix/shadcn direction semantics (LTR):
1826    // - Forward (increasing index): new content slides in from the end (right), old slides out to the start (left).
1827    // - Backward: new content slides in from the start (left), old slides out to the end (right).
1828    if to_idx > from_idx {
1829        (
1830            NavigationMenuContentMotion::ToStart,
1831            NavigationMenuContentMotion::FromEnd,
1832        )
1833    } else {
1834        (
1835            NavigationMenuContentMotion::ToEnd,
1836            NavigationMenuContentMotion::FromStart,
1837        )
1838    }
1839}
1840
1841#[derive(Default)]
1842struct ContentTransitionState {
1843    last_selected: Option<Arc<str>>,
1844    last_selected_idx: Option<usize>,
1845    from_idx: Option<usize>,
1846    to_idx: Option<usize>,
1847    seq: u64,
1848}
1849
1850#[derive(Default)]
1851struct ContentTransitionMotionState {
1852    seq: u64,
1853    last_app_tick: u64,
1854    last_frame_tick: u64,
1855    tick: u64,
1856    timeline: TransitionTimeline,
1857    lease: Option<ContinuousFrames>,
1858    configured_open_ticks: u64,
1859    configured_close_ticks: u64,
1860}
1861
1862/// Drive a Radix-like `data-motion` content transition when switching between two open values.
1863///
1864/// This is a reusable substrate for shadcn-style recipes: it exposes the direction semantics
1865/// (`from-start`/`from-end`/`to-start`/`to-end`) plus a normalized `progress` that callers can map
1866/// to transforms/opacity.
1867///
1868/// Notes:
1869/// - This helper is skin-free: it does not prescribe distances or easing beyond the function you
1870///   provide.
1871/// - It keeps the transition mounted while animating; callers decide how to keep rendering the
1872///   "from" content while `animating=true`.
1873pub fn navigation_menu_content_transition_with_durations_and_easing<H: UiHost>(
1874    cx: &mut ElementContext<'_, H>,
1875    id: fret_ui::elements::GlobalElementId,
1876    open: bool,
1877    selected: Option<Arc<str>>,
1878    values: &[Arc<str>],
1879    open_ticks: u64,
1880    close_ticks: u64,
1881    ease: fn(f32) -> f32,
1882) -> NavigationMenuContentTransitionOutput {
1883    if !open {
1884        cx.state_for(id, ContentTransitionState::default, |st| {
1885            st.last_selected = None;
1886            st.last_selected_idx = None;
1887            st.from_idx = None;
1888            st.to_idx = None;
1889        });
1890        cx.state_for(id, ContentTransitionMotionState::default, |st| {
1891            st.seq = 0;
1892            st.tick = 0;
1893            st.timeline = TransitionTimeline::default();
1894            st.lease = None;
1895        });
1896        return NavigationMenuContentTransitionOutput::default();
1897    }
1898
1899    let selected_idx = selected
1900        .as_deref()
1901        .and_then(|v| values.iter().position(|it| it.as_ref() == v));
1902
1903    let (seq, from_idx, to_idx) = cx.state_for(id, ContentTransitionState::default, |st| {
1904        let changed = selected.is_some()
1905            && st.last_selected.is_some()
1906            && selected != st.last_selected
1907            && selected_idx.is_some()
1908            && st.last_selected_idx.is_some();
1909
1910        if changed {
1911            st.from_idx = st.last_selected_idx;
1912            st.to_idx = selected_idx;
1913            st.seq = st.seq.saturating_add(1);
1914        }
1915
1916        st.last_selected = selected.clone();
1917        st.last_selected_idx = selected_idx;
1918
1919        (st.seq, st.from_idx, st.to_idx)
1920    });
1921
1922    let (Some(from_idx), Some(to_idx)) = (from_idx, to_idx) else {
1923        return NavigationMenuContentTransitionOutput::default();
1924    };
1925
1926    let (open_ticks, close_ticks) =
1927        crate::declarative::transition::effective_transition_durations_for_cx(
1928            cx,
1929            open_ticks,
1930            close_ticks,
1931        );
1932
1933    let app_tick = cx.app.tick_id().0;
1934    let frame_tick = cx.frame_id.0;
1935
1936    let (out, start_lease, stop_lease) =
1937        cx.state_for(id, ContentTransitionMotionState::default, |st| {
1938            if st.configured_open_ticks != open_ticks || st.configured_close_ticks != close_ticks {
1939                st.configured_open_ticks = open_ticks;
1940                st.configured_close_ticks = close_ticks;
1941                st.timeline.set_durations(open_ticks, close_ticks);
1942            }
1943
1944            if st.seq != seq {
1945                st.seq = seq;
1946                st.last_app_tick = app_tick;
1947                st.last_frame_tick = frame_tick;
1948                st.tick = 0;
1949                st.timeline = TransitionTimeline::default();
1950                st.timeline.set_durations(open_ticks, close_ticks);
1951            }
1952
1953            if st.last_frame_tick != frame_tick {
1954                st.last_frame_tick = frame_tick;
1955                st.tick = st.tick.saturating_add(1);
1956            } else if st.last_app_tick != app_tick {
1957                st.last_app_tick = app_tick;
1958                st.tick = st.tick.saturating_add(1);
1959            } else {
1960                st.tick = st.tick.saturating_add(1);
1961            }
1962
1963            let out = st.timeline.update_with_easing(true, st.tick, ease);
1964            let start_lease = out.animating && st.lease.is_none();
1965            let stop_lease = !out.animating && st.lease.is_some();
1966            (out, start_lease, stop_lease)
1967        });
1968
1969    if start_lease {
1970        let lease = cx.begin_continuous_frames();
1971        cx.state_for(id, ContentTransitionMotionState::default, |st| {
1972            st.lease = Some(lease);
1973        });
1974    } else if stop_lease {
1975        cx.state_for(id, ContentTransitionMotionState::default, |st| {
1976            st.lease = None;
1977        });
1978
1979        cx.state_for(id, ContentTransitionState::default, |st| {
1980            st.from_idx = None;
1981            st.to_idx = None;
1982        });
1983    }
1984
1985    if out.animating {
1986        cx.request_frame();
1987    } else {
1988        // If no continuous-frames lease was acquired (e.g. a 1-tick transition), still clear the
1989        // switch state immediately.
1990        cx.state_for(id, ContentTransitionState::default, |st| {
1991            st.from_idx = None;
1992            st.to_idx = None;
1993        });
1994    }
1995
1996    let (from_motion, to_motion) = content_motion(from_idx, to_idx);
1997    NavigationMenuContentTransitionOutput {
1998        from_idx: Some(from_idx),
1999        to_idx: Some(to_idx),
2000        switching: true,
2001        progress: out.progress,
2002        animating: out.animating,
2003        from_motion,
2004        to_motion,
2005    }
2006}
2007
2008pub fn navigation_menu_content_transition_with_durations_and_cubic_bezier<H: UiHost>(
2009    cx: &mut ElementContext<'_, H>,
2010    id: fret_ui::elements::GlobalElementId,
2011    open: bool,
2012    selected: Option<Arc<str>>,
2013    values: &[Arc<str>],
2014    open_ticks: u64,
2015    close_ticks: u64,
2016    bezier: CubicBezier,
2017) -> NavigationMenuContentTransitionOutput {
2018    if !open {
2019        cx.state_for(id, ContentTransitionState::default, |st| {
2020            st.last_selected = None;
2021            st.last_selected_idx = None;
2022            st.from_idx = None;
2023            st.to_idx = None;
2024        });
2025        cx.state_for(id, ContentTransitionMotionState::default, |st| {
2026            st.seq = 0;
2027            st.tick = 0;
2028            st.timeline = TransitionTimeline::default();
2029            st.lease = None;
2030        });
2031        return NavigationMenuContentTransitionOutput::default();
2032    }
2033
2034    let selected_idx = selected
2035        .as_deref()
2036        .and_then(|v| values.iter().position(|it| it.as_ref() == v));
2037
2038    let (seq, from_idx, to_idx) = cx.state_for(id, ContentTransitionState::default, |st| {
2039        let changed = selected.is_some()
2040            && st.last_selected.is_some()
2041            && selected != st.last_selected
2042            && selected_idx.is_some()
2043            && st.last_selected_idx.is_some();
2044
2045        if changed {
2046            st.from_idx = st.last_selected_idx;
2047            st.to_idx = selected_idx;
2048            st.seq = st.seq.saturating_add(1);
2049        }
2050
2051        st.last_selected = selected.clone();
2052        st.last_selected_idx = selected_idx;
2053
2054        (st.seq, st.from_idx, st.to_idx)
2055    });
2056
2057    let (Some(from_idx), Some(to_idx)) = (from_idx, to_idx) else {
2058        return NavigationMenuContentTransitionOutput::default();
2059    };
2060
2061    let (open_ticks, close_ticks) =
2062        crate::declarative::transition::effective_transition_durations_for_cx(
2063            cx,
2064            open_ticks,
2065            close_ticks,
2066        );
2067
2068    let app_tick = cx.app.tick_id().0;
2069    let frame_tick = cx.frame_id.0;
2070
2071    let (out, start_lease, stop_lease) =
2072        cx.state_for(id, ContentTransitionMotionState::default, |st| {
2073            if st.configured_open_ticks != open_ticks || st.configured_close_ticks != close_ticks {
2074                st.configured_open_ticks = open_ticks;
2075                st.configured_close_ticks = close_ticks;
2076                st.timeline.set_durations(open_ticks, close_ticks);
2077            }
2078
2079            if st.seq != seq {
2080                st.seq = seq;
2081                st.last_app_tick = app_tick;
2082                st.last_frame_tick = frame_tick;
2083                st.tick = 0;
2084                st.timeline = TransitionTimeline::default();
2085                st.timeline.set_durations(open_ticks, close_ticks);
2086            }
2087
2088            if st.last_frame_tick != frame_tick {
2089                st.last_frame_tick = frame_tick;
2090                st.tick = st.tick.saturating_add(1);
2091            } else if st.last_app_tick != app_tick {
2092                st.last_app_tick = app_tick;
2093                st.tick = st.tick.saturating_add(1);
2094            } else {
2095                st.tick = st.tick.saturating_add(1);
2096            }
2097
2098            let out = st.timeline.update_with_cubic_bezier(
2099                true, st.tick, bezier.x1, bezier.y1, bezier.x2, bezier.y2,
2100            );
2101            let start_lease = out.animating && st.lease.is_none();
2102            let stop_lease = !out.animating && st.lease.is_some();
2103            (out, start_lease, stop_lease)
2104        });
2105
2106    if start_lease {
2107        let lease = cx.begin_continuous_frames();
2108        cx.state_for(id, ContentTransitionMotionState::default, |st| {
2109            st.lease = Some(lease);
2110        });
2111    } else if stop_lease {
2112        cx.state_for(id, ContentTransitionMotionState::default, |st| {
2113            st.lease = None;
2114        });
2115
2116        cx.state_for(id, ContentTransitionState::default, |st| {
2117            st.from_idx = None;
2118            st.to_idx = None;
2119        });
2120    }
2121
2122    if out.animating {
2123        cx.request_frame();
2124    } else {
2125        // If no continuous-frames lease was acquired (e.g. a 1-tick transition), still clear the
2126        // switch state immediately.
2127        cx.state_for(id, ContentTransitionState::default, |st| {
2128            st.from_idx = None;
2129            st.to_idx = None;
2130        });
2131    }
2132
2133    let (from_motion, to_motion) = content_motion(from_idx, to_idx);
2134    NavigationMenuContentTransitionOutput {
2135        from_idx: Some(from_idx),
2136        to_idx: Some(to_idx),
2137        switching: true,
2138        progress: out.progress,
2139        animating: out.animating,
2140        from_motion,
2141        to_motion,
2142    }
2143}
2144
2145pub fn navigation_menu_content_transition_with_durations_and_cubic_bezier_duration<H: UiHost>(
2146    cx: &mut ElementContext<'_, H>,
2147    id: fret_ui::elements::GlobalElementId,
2148    open: bool,
2149    selected: Option<Arc<str>>,
2150    values: &[Arc<str>],
2151    open_duration: Duration,
2152    close_duration: Duration,
2153    bezier: CubicBezier,
2154) -> NavigationMenuContentTransitionOutput {
2155    let open_ticks = crate::declarative::transition::ticks_60hz_for_duration(open_duration);
2156    let close_ticks = crate::declarative::transition::ticks_60hz_for_duration(close_duration);
2157    navigation_menu_content_transition_with_durations_and_cubic_bezier(
2158        cx,
2159        id,
2160        open,
2161        selected,
2162        values,
2163        open_ticks,
2164        close_ticks,
2165        bezier,
2166    )
2167}
2168
2169/// Convenience wrapper that uses shadcn-style defaults.
2170pub fn navigation_menu_content_transition<H: UiHost>(
2171    cx: &mut ElementContext<'_, H>,
2172    id: fret_ui::elements::GlobalElementId,
2173    open: bool,
2174    selected: Option<Arc<str>>,
2175    values: &[Arc<str>],
2176) -> NavigationMenuContentTransitionOutput {
2177    navigation_menu_content_transition_with_durations_and_cubic_bezier_duration(
2178        cx,
2179        id,
2180        open,
2181        selected,
2182        values,
2183        crate::declarative::overlay_motion::motion_layout_expand_duration(cx),
2184        crate::declarative::overlay_motion::motion_layout_expand_duration(cx),
2185        crate::declarative::overlay_motion::motion_layout_expand_ease_bezier(cx),
2186    )
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191    use super::*;
2192
2193    use fret_app::App;
2194    use fret_core::{AppWindowId, Point, Px, Rect, Size};
2195    use fret_ui::GlobalElementId;
2196    use fret_ui::action::UiActionHostAdapter;
2197    use fret_ui::element::{AnyElement, ElementKind, PressableProps};
2198
2199    fn acx(window: AppWindowId) -> ActionCx {
2200        ActionCx {
2201            window,
2202            target: GlobalElementId(0x1),
2203        }
2204    }
2205
2206    #[test]
2207    fn trigger_enter_is_delayed_by_default_and_opens_after_timer() {
2208        let window = AppWindowId::default();
2209        let mut app = App::new();
2210        let value = app.models_mut().insert(None::<Arc<str>>);
2211        let mut host = UiActionHostAdapter { app: &mut app };
2212
2213        let mut st = NavigationMenuRootState::default();
2214        let cfg = NavigationMenuConfig::default();
2215
2216        st.on_trigger_enter(&mut host, acx(window), &value, Arc::from("a"), cfg);
2217        assert_eq!(
2218            host.models_mut().read(&value, |v| v.clone()).ok().flatten(),
2219            None
2220        );
2221
2222        let effects = host.app.flush_effects();
2223        let token = effects
2224            .iter()
2225            .find_map(|e| match e {
2226                Effect::SetTimer { token, after, .. } if *after == cfg.delay_duration => {
2227                    Some(*token)
2228                }
2229                _ => None,
2230            })
2231            .expect("expected open timer");
2232
2233        assert!(st.on_timer(&mut host, acx(window), token, &value, cfg));
2234        assert_eq!(
2235            host.models_mut()
2236                .read(&value, |v| v.clone())
2237                .ok()
2238                .flatten()
2239                .as_deref(),
2240            Some("a")
2241        );
2242        assert!(
2243            !st.is_open_delayed(),
2244            "expected skip-delay window to be active"
2245        );
2246    }
2247
2248    #[test]
2249    fn closing_enables_immediate_open_within_skip_delay_window() {
2250        let window = AppWindowId::default();
2251        let mut app = App::new();
2252        let value = app.models_mut().insert(Some(Arc::from("a")));
2253        let mut host = UiActionHostAdapter { app: &mut app };
2254
2255        let mut st = NavigationMenuRootState::default();
2256        let cfg = NavigationMenuConfig::default();
2257
2258        // Mark as opened (Radix sets isOpenDelayed=false while open).
2259        st.note_opened(&mut host, cfg);
2260        assert!(!st.is_open_delayed());
2261
2262        // Dismiss closes and arms the skip-delay timer, while keeping `is_open_delayed=false`
2263        // until it fires.
2264        st.on_item_dismiss(&mut host, acx(window), &value, cfg);
2265        assert_eq!(
2266            host.models_mut().read(&value, |v| v.clone()).ok().flatten(),
2267            None
2268        );
2269        assert!(!st.is_open_delayed());
2270
2271        host.app.flush_effects();
2272
2273        // Within the skip window: entering a trigger opens immediately (no open timer).
2274        st.on_trigger_enter(&mut host, acx(window), &value, Arc::from("b"), cfg);
2275        assert_eq!(
2276            host.models_mut()
2277                .read(&value, |v| v.clone())
2278                .ok()
2279                .flatten()
2280                .as_deref(),
2281            Some("b")
2282        );
2283        let effects = host.app.flush_effects();
2284        assert!(
2285            effects.iter().all(
2286                |e| !matches!(e, Effect::SetTimer { after, .. } if *after == cfg.delay_duration)
2287            ),
2288            "expected immediate open (no delayed-open timer)"
2289        );
2290    }
2291
2292    #[test]
2293    fn trigger_leave_starts_close_timer_and_content_enter_cancels_it() {
2294        let window = AppWindowId::default();
2295        let mut app = App::new();
2296        let value = app.models_mut().insert(Some(Arc::from("a")));
2297        let mut host = UiActionHostAdapter { app: &mut app };
2298
2299        let mut st = NavigationMenuRootState::default();
2300        let cfg = NavigationMenuConfig::default();
2301
2302        st.on_trigger_leave(&mut host, acx(window), &value, cfg);
2303        let effects = host.app.flush_effects();
2304        let close_token = effects
2305            .iter()
2306            .find_map(|e| match e {
2307                Effect::SetTimer { token, after, .. } if *after == cfg.close_delay_duration => {
2308                    Some(*token)
2309                }
2310                _ => None,
2311            })
2312            .expect("expected close timer");
2313
2314        // Content enter cancels the close timer.
2315        st.on_content_enter(&mut host);
2316        let effects = host.app.flush_effects();
2317        assert!(
2318            effects
2319                .iter()
2320                .any(|e| matches!(e, Effect::CancelTimer { token } if *token == close_token)),
2321            "expected close timer cancellation"
2322        );
2323    }
2324
2325    #[test]
2326    fn trigger_pointer_move_gate_matches_radix_outcomes() {
2327        let st = NavigationMenuTriggerState::default();
2328        assert_eq!(
2329            navigation_menu_trigger_pointer_move_action(PointerType::Mouse, false, st),
2330            NavigationMenuTriggerPointerMoveAction::Open
2331        );
2332        assert_eq!(
2333            navigation_menu_trigger_pointer_move_action(PointerType::Touch, false, st),
2334            NavigationMenuTriggerPointerMoveAction::Ignore
2335        );
2336
2337        let st = NavigationMenuTriggerState {
2338            has_pointer_move_opened: true,
2339            ..Default::default()
2340        };
2341        assert_eq!(
2342            navigation_menu_trigger_pointer_move_action(PointerType::Mouse, false, st),
2343            NavigationMenuTriggerPointerMoveAction::Ignore
2344        );
2345    }
2346
2347    #[test]
2348    fn content_motion_matches_forward_and_backward_semantics() {
2349        let (from, to) = content_motion(0, 1);
2350        assert_eq!(from, NavigationMenuContentMotion::ToStart);
2351        assert_eq!(to, NavigationMenuContentMotion::FromEnd);
2352
2353        let (from, to) = content_motion(2, 1);
2354        assert_eq!(from, NavigationMenuContentMotion::ToEnd);
2355        assert_eq!(to, NavigationMenuContentMotion::FromStart);
2356    }
2357
2358    #[test]
2359    fn content_switch_exposes_from_to_and_direction() {
2360        let transition = NavigationMenuContentTransitionOutput {
2361            from_idx: Some(0),
2362            to_idx: Some(2),
2363            switching: true,
2364            progress: 0.25,
2365            animating: true,
2366            from_motion: NavigationMenuContentMotion::ToStart,
2367            to_motion: NavigationMenuContentMotion::FromEnd,
2368        };
2369        let out = navigation_menu_content_switch(transition).expect("switch");
2370        assert_eq!(out.from_idx, 0);
2371        assert_eq!(out.to_idx, 2);
2372        assert!(out.forward);
2373        assert_eq!(out.progress, 0.25);
2374        assert_eq!(out.from_motion, NavigationMenuContentMotion::ToStart);
2375        assert_eq!(out.to_motion, NavigationMenuContentMotion::FromEnd);
2376    }
2377
2378    fn bounds() -> Rect {
2379        Rect::new(
2380            Point::new(Px(0.0), Px(0.0)),
2381            Size::new(Px(200.0), Px(120.0)),
2382        )
2383    }
2384
2385    #[test]
2386    fn viewport_size_interpolates_between_registered_sizes() {
2387        let window = AppWindowId::default();
2388        let mut app = App::new();
2389
2390        fret_ui::elements::with_element_cx(&mut app, window, bounds(), "test", |cx| {
2391            let root_id = cx.root_id();
2392            let a: Arc<str> = Arc::from("a");
2393            let b: Arc<str> = Arc::from("b");
2394            let values = vec![a.clone(), b.clone()];
2395
2396            navigation_menu_register_viewport_size(
2397                cx,
2398                root_id,
2399                a.clone(),
2400                Size::new(Px(100.0), Px(50.0)),
2401            );
2402            navigation_menu_register_viewport_size(
2403                cx,
2404                root_id,
2405                b.clone(),
2406                Size::new(Px(200.0), Px(150.0)),
2407            );
2408
2409            let transition = NavigationMenuContentTransitionOutput {
2410                from_idx: Some(0),
2411                to_idx: Some(1),
2412                switching: true,
2413                progress: 0.5,
2414                animating: true,
2415                from_motion: NavigationMenuContentMotion::ToStart,
2416                to_motion: NavigationMenuContentMotion::FromEnd,
2417            };
2418
2419            let out = navigation_menu_viewport_size_for_transition(
2420                cx,
2421                root_id,
2422                Some(b.clone()),
2423                &values,
2424                transition,
2425                Size::new(Px(10.0), Px(10.0)),
2426            );
2427            assert_eq!(out.size, Size::new(Px(150.0), Px(100.0)));
2428        });
2429    }
2430
2431    #[test]
2432    fn viewport_selected_value_is_stable_while_present() {
2433        let window = AppWindowId::default();
2434        let mut app = App::new();
2435
2436        fret_ui::elements::with_element_cx(&mut app, window, bounds(), "test", |cx| {
2437            let root_id = cx.root_id();
2438
2439            let a: Arc<str> = Arc::from("a");
2440            let b: Arc<str> = Arc::from("b");
2441
2442            assert_eq!(
2443                navigation_menu_viewport_selected_value(cx, root_id, Some(a.clone()), false)
2444                    .as_deref(),
2445                Some("a")
2446            );
2447            assert_eq!(
2448                navigation_menu_viewport_selected_value(cx, root_id, None, true).as_deref(),
2449                Some("a")
2450            );
2451            assert_eq!(
2452                navigation_menu_viewport_selected_value(cx, root_id, Some(b.clone()), true)
2453                    .as_deref(),
2454                Some("b")
2455            );
2456            assert_eq!(
2457                navigation_menu_viewport_selected_value(cx, root_id, None, false).as_deref(),
2458                None
2459            );
2460        });
2461    }
2462
2463    #[test]
2464    fn indicator_rect_tracks_anchor_center_and_viewport_edge() {
2465        let anchor = Rect::new(
2466            Point::new(Px(10.0), Px(20.0)),
2467            Size::new(Px(100.0), Px(40.0)),
2468        );
2469        let viewport = Rect::new(
2470            Point::new(Px(0.0), Px(100.0)),
2471            Size::new(Px(200.0), Px(80.0)),
2472        );
2473        let out = navigation_menu_indicator_rect(anchor, viewport, Side::Bottom, Px(6.0));
2474        assert_eq!(out.origin.x, Px(10.0));
2475        assert_eq!(out.origin.y, Px(100.0 - 6.0));
2476        assert_eq!(out.size, Size::new(Px(100.0), Px(6.0)));
2477    }
2478
2479    #[test]
2480    fn navigation_menu_link_suppresses_dismiss_on_ctrl_or_meta_by_default() {
2481        assert!(
2482            navigation_menu_link_suppresses_dismiss(
2483                Modifiers {
2484                    ctrl: true,
2485                    ..Default::default()
2486                },
2487                false
2488            ),
2489            "expected ctrl to suppress dismiss"
2490        );
2491        assert!(
2492            navigation_menu_link_suppresses_dismiss(
2493                Modifiers {
2494                    meta: true,
2495                    ..Default::default()
2496                },
2497                false
2498            ),
2499            "expected meta to suppress dismiss"
2500        );
2501        assert!(
2502            !navigation_menu_link_suppresses_dismiss(Modifiers::default(), false),
2503            "expected unmodified to not suppress dismiss"
2504        );
2505        assert!(
2506            !navigation_menu_link_suppresses_dismiss(
2507                Modifiers {
2508                    meta: true,
2509                    ..Default::default()
2510                },
2511                true
2512            ),
2513            "expected opt-in to allow dismiss on modified select"
2514        );
2515    }
2516
2517    #[test]
2518    fn viewport_content_semantics_id_matches_mounted_content_id() {
2519        let window = AppWindowId::default();
2520        let mut app = App::new();
2521
2522        fret_ui::elements::with_element_cx(&mut app, window, bounds(), "test", |cx| {
2523            let root_id = cx.root_id();
2524            let overlay_root_name = "nav-menu-overlay";
2525            let value = "alpha";
2526            let expected = navigation_menu_viewport_content_semantics_id::<App>(
2527                cx,
2528                root_id,
2529                overlay_root_name,
2530                value,
2531            );
2532            let actual = cx.with_root_name(overlay_root_name, |cx| {
2533                navigation_menu_viewport_content_pressable_with_id_props::<App>(
2534                    cx,
2535                    root_id,
2536                    value,
2537                    |_cx, _st, _id| {
2538                        (
2539                            fret_ui::element::PressableProps {
2540                                layout: LayoutStyle::default(),
2541                                enabled: true,
2542                                focusable: false,
2543                                ..Default::default()
2544                            },
2545                            Vec::new(),
2546                        )
2547                    },
2548                )
2549                .id
2550            });
2551            assert_eq!(expected, actual);
2552        });
2553    }
2554
2555    #[test]
2556    fn find_first_focus_target_returns_first_enabled_focusable_in_tree_order() {
2557        let first = GlobalElementId(0x10);
2558        let later = GlobalElementId(0x11);
2559        let elements = vec![
2560            AnyElement::new(
2561                GlobalElementId(0x1),
2562                ElementKind::Pressable(PressableProps {
2563                    enabled: false,
2564                    ..Default::default()
2565                }),
2566                vec![AnyElement::new(
2567                    first,
2568                    ElementKind::Pressable(PressableProps {
2569                        enabled: true,
2570                        ..Default::default()
2571                    }),
2572                    Vec::new(),
2573                )],
2574            ),
2575            AnyElement::new(
2576                later,
2577                ElementKind::Pressable(PressableProps {
2578                    enabled: true,
2579                    ..Default::default()
2580                }),
2581                Vec::new(),
2582            ),
2583        ];
2584
2585        assert_eq!(find_first_focus_target(&elements), Some(first));
2586    }
2587}