Skip to main content

tessera_components/
menus.rs

1//! Material Design 3 menus for contextual action lists.
2//!
3//! ## Usage
4//!
5//! Present anchored overflow or context actions as surfaced menus.
6use tessera_ui::{
7    AxisConstraint, Callback, ClipRegion, ClipShape, Color, ComputedData, CursorEventContent, Dp,
8    DrawModifierContent, DrawModifierContext, DrawModifierNode, FocusDirection, FocusManager,
9    FocusRequester, FocusScopeNode, FocusTraversalPolicy, FrameNanosControl, KeyboardInput,
10    KeyboardInputModifierNode, LayoutResult, MOUSE_POINTER_ID, MeasurementError, Modifier,
11    ParentConstraint, PointerInput, PointerInputModifierNode, Px, PxPosition, PxSize, RenderSlot,
12    RenderSlotWith, State,
13    accesskit::{HasPopup, Role, Toggled},
14    current_frame_nanos,
15    layout::{LayoutPlacementTarget, MeasureScope, PlacementResult, PlacementScope, layout},
16    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
17    provide_context, receive_frame_nanos, remember, tessera, use_context, winit,
18};
19
20use crate::{
21    alignment::CrossAxisAlignment,
22    checkmark::checkmark,
23    column::column,
24    icon::icon,
25    material_icons::filled,
26    modifier::{ModifierExt as _, with_keyboard_input, with_pointer_input},
27    painter::Painter,
28    pos_misc::is_position_in_rect,
29    row::row,
30    scrollable::ScrollableController,
31    shape_def::Shape,
32    spacer::spacer,
33    surface::{SurfaceStyle, surface},
34    text::text,
35    theme::{MaterialAlpha, MaterialTheme},
36};
37
38use host::{MenuHost, MenuPopupLease, MenuPopupRequest, menu_host};
39use motion::{MenuShapeHover, MenuShapeMotion};
40
41pub use appearance::{MenuColorStyle, MenuDefaults, MenuPosition, menu_group};
42
43mod appearance;
44mod host;
45mod motion;
46mod typeahead;
47
48const MENU_MIN_WIDTH: Dp = Dp(112.0);
49const MENU_MAX_WIDTH: Dp = Dp(280.0);
50const MENU_MAX_HEIGHT: Dp = Dp(320.0);
51const MENU_VERTICAL_GAP: Dp = Dp(4.0);
52const MENU_HORIZONTAL_PADDING: Dp = Dp(12.0);
53const MENU_LEADING_SIZE: Dp = Dp(20.0);
54const MENU_ITEM_HEIGHT: Dp = MenuDefaults::ITEM_HEIGHT;
55const MENU_TRAILING_SPACING: Dp = Dp(12.0);
56const MENU_HOVER_DELAY_NANOS: u64 = 150_000_000;
57
58fn default_menu_width() -> AxisConstraint {
59    AxisConstraint::new(Px::from(MENU_MIN_WIDTH), Some(Px::from(MENU_MAX_WIDTH)))
60}
61
62fn default_menu_modifier() -> Modifier {
63    Modifier::new().constrain(Some(default_menu_width()), None)
64}
65
66fn default_max_height() -> Option<Px> {
67    Some(Px::from(MENU_MAX_HEIGHT))
68}
69
70fn default_menu_shape() -> Shape {
71    MenuDefaults::group_shape(MenuPosition::Standalone)
72}
73
74fn default_menu_color() -> Color {
75    Color::new(1.0, 1.0, 1.0, 1.0)
76}
77
78fn default_scrim_color() -> Color {
79    Color::new(0.0, 0.0, 0.0, 0.0)
80}
81
82/// Describes the anchor rectangle used to position a menu.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct MenuAnchor {
85    /// Top-left corner of the anchor rectangle, relative to the menu container.
86    pub origin: PxPosition,
87    /// Size of the anchor rectangle.
88    pub size: PxSize,
89}
90
91impl MenuAnchor {
92    /// Creates a new anchor rectangle from origin and size.
93    pub fn new(origin: PxPosition, size: PxSize) -> Self {
94        Self { origin, size }
95    }
96
97    /// Creates an anchor positioned at `origin` with zero size.
98    pub fn at(origin: PxPosition) -> Self {
99        Self::new(origin, PxSize::new(Px::ZERO, Px::ZERO))
100    }
101
102    /// Creates an anchor from dp values for origin and size.
103    pub fn from_dp(origin: (Dp, Dp), size: (Dp, Dp)) -> Self {
104        Self::new(
105            PxPosition::new(origin.0.into(), origin.1.into()),
106            PxSize::new(size.0.into(), size.1.into()),
107        )
108    }
109}
110
111impl Default for MenuAnchor {
112    fn default() -> Self {
113        Self::at(PxPosition::new(Px::ZERO, Px::ZERO))
114    }
115}
116
117/// Shared state for controlling menu visibility and anchor placement.
118#[derive(Default, Clone, PartialEq)]
119pub struct MenuController {
120    is_open: bool,
121    anchor: Option<MenuAnchor>,
122    exit_direction: Option<FocusDirection>,
123}
124
125impl MenuController {
126    /// Creates a new closed menu state.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Opens the menu.
132    ///
133    /// If an anchor was previously set via [`open_at`](Self::open_at), it is
134    /// reused. Otherwise, the menu defaults to anchoring to the provider's
135    /// content.
136    pub fn open(&mut self) {
137        self.is_open = true;
138        self.exit_direction = None;
139    }
140
141    /// Opens the menu at the provided anchor rectangle.
142    ///
143    /// An explicit `anchor` prop on the provider takes precedence over this
144    /// saved rectangle.
145    pub fn open_at(&mut self, anchor: MenuAnchor) {
146        self.anchor = Some(anchor);
147        self.is_open = true;
148        self.exit_direction = None;
149    }
150
151    /// Closes the menu.
152    pub fn close(&mut self) {
153        self.is_open = false;
154        self.exit_direction = None;
155    }
156
157    /// Toggles the open state, keeping the current anchor.
158    pub fn toggle(&mut self) {
159        self.is_open = !self.is_open;
160        self.exit_direction = None;
161    }
162
163    /// Returns whether the menu is currently open.
164    pub fn is_open(&self) -> bool {
165        self.is_open
166    }
167
168    fn snapshot(&self) -> (bool, Option<MenuAnchor>) {
169        (self.is_open, self.anchor)
170    }
171}
172
173/// Controls how the menu is aligned relative to its anchor.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub enum MenuPlacement {
176    /// Align to the anchor's start edge and expand downward.
177    #[default]
178    BelowStart,
179    /// Align to the anchor's end edge and expand downward.
180    BelowEnd,
181    /// Align to the anchor's start edge and expand upward.
182    AboveStart,
183    /// Align to the anchor's end edge and expand upward.
184    AboveEnd,
185    /// Align to the anchor's end edge and expand rightward.
186    RightStart,
187    /// Align to the anchor's start edge and expand leftward.
188    LeftStart,
189}
190
191/// The owner of actions declared in one open menu.
192#[derive(Clone, Copy, PartialEq)]
193pub struct MenuScope {
194    host: MenuHost,
195    controller: State<MenuController>,
196    typeahead: typeahead::MenuTypeahead,
197}
198
199#[derive(Clone, PartialEq)]
200struct MenuProviderConfig {
201    pub host: Option<MenuHost>,
202    pub anchor: Option<MenuAnchor>,
203    pub window_anchor: Option<State<tessera_ui::LayoutCoordinates>>,
204    pub bounds: Option<MenuBoundsHandle>,
205    pub focus_scope: Option<FocusScopeNode>,
206    pub parent_request: Option<State<MenuPopupRequest>>,
207    pub grouped: bool,
208    pub color_style: MenuColorStyle,
209    pub placement: MenuPlacement,
210    pub offset: [Dp; 2],
211    pub modifier: Modifier,
212    pub max_height: Option<Px>,
213    pub shape: Shape,
214    pub animate_shape: bool,
215    pub elevation: Dp,
216    pub container_color: Color,
217    pub scrim_color: Color,
218    pub close_on_background: bool,
219    pub close_on_escape: bool,
220    pub on_dismiss: Option<Callback>,
221    pub is_open: bool,
222    pub controller: Option<State<MenuController>>,
223    pub focus_restorer_fallback: Option<FocusRequester>,
224    pub main_content: Option<RenderSlot>,
225    pub menu_content: Option<RenderSlotWith<MenuScope>>,
226}
227
228impl Default for MenuProviderConfig {
229    fn default() -> Self {
230        Self {
231            host: None,
232            anchor: None,
233            window_anchor: None,
234            bounds: None,
235            focus_scope: None,
236            parent_request: None,
237            grouped: false,
238            color_style: MenuColorStyle::Standard,
239            placement: MenuPlacement::default(),
240            offset: [Dp(0.0), MENU_VERTICAL_GAP],
241            modifier: default_menu_modifier(),
242            max_height: default_max_height(),
243            shape: default_menu_shape(),
244            animate_shape: true,
245            elevation: Dp(3.0),
246            container_color: default_menu_color(),
247            scrim_color: default_scrim_color(),
248            close_on_background: true,
249            close_on_escape: true,
250            on_dismiss: None,
251            is_open: false,
252            controller: None,
253            focus_restorer_fallback: None,
254            main_content: None,
255            menu_content: None,
256        }
257    }
258}
259
260#[derive(Clone, PartialEq, Copy)]
261struct MenuBounds {
262    origin: PxPosition,
263    size: ComputedData,
264}
265
266#[derive(Clone, Copy)]
267struct MenuBoundsHandle(State<Option<MenuBounds>>);
268
269impl PartialEq for MenuBoundsHandle {
270    fn eq(&self, other: &Self) -> bool {
271        self.0 == other.0
272    }
273}
274
275impl Default for MenuBounds {
276    fn default() -> Self {
277        Self {
278            origin: PxPosition::new(Px::ZERO, Px::ZERO),
279            size: ComputedData::ZERO,
280        }
281    }
282}
283
284#[derive(Clone, Copy, PartialEq)]
285enum MenuAnchorSource {
286    Local(Option<tessera_ui::FloatRect>),
287    Window {
288        anchor: State<tessera_ui::LayoutCoordinates>,
289        origin: State<tessera_ui::LayoutCoordinates>,
290    },
291}
292
293impl MenuAnchorSource {
294    fn resolve(self, main: ComputedData) -> tessera_ui::FloatRect {
295        match self {
296            Self::Local(anchor) => anchor.unwrap_or_else(|| {
297                tessera_ui::FloatRect::from_position_size(PxPosition::ZERO, main.into())
298            }),
299            Self::Window { anchor, origin } => origin
300                .get()
301                .local_to_window
302                .inverse_rect(anchor.get().window_bounds()),
303        }
304    }
305}
306
307#[derive(Clone)]
308struct MenuLayout {
309    is_open: bool,
310    has_background: bool,
311    placement: MenuPlacement,
312    offset: [Dp; 2],
313    anchor: MenuAnchorSource,
314    bounds: State<Option<MenuBounds>>,
315}
316
317struct MenuScrollClip {
318    controller: State<ScrollableController>,
319}
320
321impl DrawModifierNode for MenuScrollClip {
322    fn draw(&self, ctx: &mut DrawModifierContext<'_, '_>, content: &mut dyn DrawModifierContent) {
323        let mut metadata = ctx.render_input.metadata_mut();
324        let size = metadata.computed_data().expect("measured menu viewport");
325        let overflow = self
326            .controller
327            .with(|scroll| scroll.child_size().height > size.height);
328        // Group shadows need paint overflow; active scrolling still crops
329        // content at the viewport's top and bottom edges.
330        let margin = Dp(16.0).to_pixels_f32();
331        let vertical_margin = if overflow { 0.0 } else { margin };
332        metadata.set_clip_region(ClipRegion {
333            position: PxPosition::from_f32_xy(-margin, -vertical_margin),
334            size: [
335                size.width.to_f32() + 2.0 * margin,
336                size.height.to_f32() + 2.0 * vertical_margin,
337            ],
338            shape: ClipShape::RoundedRectangle {
339                radii: [0.0; 4],
340                exponents: [2.0; 4],
341            },
342        });
343        content.draw(ctx.render_input);
344    }
345}
346
347impl PartialEq for MenuLayout {
348    fn eq(&self, other: &Self) -> bool {
349        self.is_open == other.is_open
350            && self.has_background == other.has_background
351            && self.placement == other.placement
352            && self.offset == other.offset
353            && self.anchor == other.anchor
354            && self.bounds == other.bounds
355    }
356}
357
358impl MenuLayout {
359    fn place<T: LayoutPlacementTarget>(
360        &self,
361        children: &[T],
362        sizes: &[ComputedData],
363        fallback: ComputedData,
364    ) -> LayoutResult {
365        let main = sizes[0];
366        let mut result = LayoutResult::new(main);
367        result.place_child(children[0], PxPosition::ZERO);
368        if !self.is_open {
369            self.bounds.set_if_changed(None);
370            return result;
371        }
372        let (menu_index, available) = if self.has_background {
373            result.place_child(children[1], PxPosition::ZERO);
374            let background = sizes[1];
375            (
376                2,
377                if background.width > Px::ZERO && background.height > Px::ZERO {
378                    background
379                } else {
380                    fallback
381                },
382            )
383        } else {
384            (1, fallback)
385        };
386        let menu_size = sizes[menu_index];
387        let position = resolve_menu_position(
388            self.anchor.resolve(main),
389            self.placement,
390            menu_size,
391            available,
392            self.offset,
393        );
394        result.place_child(children[menu_index], position);
395        self.bounds.set_if_changed(Some(MenuBounds {
396            origin: position,
397            size: menu_size,
398        }));
399        result
400    }
401}
402
403impl tessera_ui::LayoutModifierNode for MenuLayout {
404    fn measure_eq(&self, other: &Self) -> bool {
405        self.is_open == other.is_open && self.has_background == other.has_background
406    }
407
408    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
409        let children = input.children();
410        let constraint = input.parent_constraint().without_min();
411        let sizes = children
412            .iter()
413            .map(|child| child.measure(input, &constraint).map(|child| child.size()))
414            .collect::<Result<Vec<_>, _>>()?;
415        let fallback = extract_available_size(input.parent_constraint());
416        Ok(input.placement(|| self.place(&children, &sizes, fallback)))
417    }
418
419    fn place_children(
420        &self,
421        scope: &PlacementScope<'_>,
422        _inner: &PlacementResult,
423    ) -> Option<PlacementResult> {
424        let children = scope.children();
425        let sizes = children
426            .iter()
427            .map(|child| child.size())
428            .collect::<Vec<_>>();
429        Some(
430            self.place(
431                &children,
432                &sizes,
433                extract_available_size(scope.parent_constraint()),
434            )
435            .into_placement_result(),
436        )
437    }
438}
439
440fn resolve_menu_position(
441    anchor: tessera_ui::FloatRect,
442    placement: MenuPlacement,
443    menu_size: ComputedData,
444    available: ComputedData,
445    offset: [Dp; 2],
446) -> PxPosition {
447    let anchor_x = anchor.x;
448    let anchor_y = anchor.y;
449    let anchor_end_x = anchor_x + anchor.width;
450    let anchor_end_y = anchor_y + anchor.height;
451
452    let width = menu_size.width.to_f32();
453    let height = menu_size.height.to_f32();
454    let dx = offset[0].to_pixels_f32();
455    let dy = offset[1].to_pixels_f32();
456    let start_x = anchor_x + dx;
457    let end_x = anchor_end_x - width + dx;
458    let above = anchor_y - height - dy;
459    let below = anchor_end_y + dy;
460    let right = anchor_end_x + dx;
461    let left = anchor_x - width - dx;
462    let start_y = anchor_y + dy;
463    let end_y = anchor_end_y - height + dy;
464    let (x, alternate_x, y, alternate_y) = match placement {
465        MenuPlacement::BelowStart => (start_x, end_x, below, above),
466        MenuPlacement::BelowEnd => (end_x, start_x, below, above),
467        MenuPlacement::AboveStart => (start_x, end_x, above, below),
468        MenuPlacement::AboveEnd => (end_x, start_x, above, below),
469        MenuPlacement::RightStart => (right, left, start_y, end_y),
470        MenuPlacement::LeftStart => (left, right, start_y, end_y),
471    };
472    PxPosition::from_f32_xy(
473        fit_menu_axis(x, alternate_x, width, available.width.to_f32()),
474        fit_menu_axis(y, alternate_y, height, available.height.to_f32()),
475    )
476}
477
478fn fit_menu_axis(preferred: f32, alternate: f32, extent: f32, available: f32) -> f32 {
479    let max = (available - extent).max(0.0);
480    if preferred >= 0.0 && preferred <= max {
481        preferred
482    } else if alternate >= 0.0 && alternate <= max {
483        alternate
484    } else {
485        preferred.clamp(0.0, max)
486    }
487}
488
489fn extract_available_size(constraint: ParentConstraint<'_>) -> ComputedData {
490    let width = constraint.width().resolve_max().unwrap_or(Px::MAX);
491    let height = constraint.height().resolve_max().unwrap_or(Px::MAX);
492
493    ComputedData { width, height }
494}
495
496fn is_click_outside_menu(cursor_position: Option<PxPosition>, bounds: Option<MenuBounds>) -> bool {
497    let Some(bounds) = bounds else {
498        return false;
499    };
500
501    cursor_position
502        .map(|pos| !is_position_in_rect(pos, bounds.origin, bounds.size.width, bounds.size.height))
503        .unwrap_or(false)
504}
505
506fn apply_close_action(controller: State<MenuController>, on_dismiss: &Option<Callback>) {
507    if let Some(callback) = on_dismiss {
508        callback.call();
509    }
510    controller.with_mut(|c| c.close());
511}
512
513fn close_for_traversal(
514    controller: State<MenuController>,
515    on_dismiss: &Option<Callback>,
516    reverse: bool,
517) {
518    apply_close_action(controller, on_dismiss);
519    controller.with_mut(|controller| {
520        controller.exit_direction = Some(if reverse {
521            FocusDirection::Previous
522        } else {
523            FocusDirection::Next
524        });
525    });
526}
527
528struct MenuKeyboardBoundary {
529    controller: State<MenuController>,
530    rendered_open: bool,
531}
532
533impl MenuKeyboardBoundary {
534    fn check(&self) {
535        if self.controller.with(MenuController::is_open) != self.rendered_open {
536            FocusManager::current().defer_keyboard_until_layout();
537        }
538    }
539}
540
541impl KeyboardInputModifierNode for MenuKeyboardBoundary {
542    fn on_keyboard_input(&self, _input: KeyboardInput<'_>) {
543        self.check();
544    }
545}
546
547impl PointerInputModifierNode for MenuKeyboardBoundary {
548    fn on_pointer_input(&self, _input: PointerInput<'_>) {
549        self.check();
550    }
551}
552
553/// # menu_provider
554///
555/// Provides a Material Design 3 menu overlay anchored to a rectangle.
556///
557/// ## Usage
558///
559/// Wrap page content and show contextual or overflow actions aligned to a
560/// trigger element.
561///
562/// ## Parameters
563///
564/// - `anchor` — current anchor relative to the provider; takes precedence over
565///   the controller's saved anchor and can follow layout changes
566/// - `window_anchor` — observed trigger coordinates, including ancestor transforms;
567///   takes precedence over `anchor`
568/// - `placement` — how the menu aligns relative to its anchor
569/// - `grouped` — render separate `menu_group` surfaces on a transparent panel
570/// - `color_style` — standard or vibrant colors, inherited by groups and items
571/// - `offset` — x/y distance away from the anchor on the expansion axis, and a
572///   shift on the other axis
573/// - `modifier` — extra layout modifiers for the menu container
574/// - `max_height` — optional maximum menu height before scrolling
575/// - `shape` — optional fixed panel shape; default corners respond to hover and
576///   keyboard focus
577/// - `elevation` — menu elevation level
578/// - `container_color` — background color of the menu panel
579/// - `scrim_color` — background color behind the menu
580/// - `close_on_background` — whether outside clicks dismiss the menu
581/// - `close_on_escape` — whether Escape dismisses the menu
582/// - `on_dismiss` — optional callback before dismissal
583/// - `is_open` — whether the menu is currently visible
584/// - `controller` — optional external controller for open state and anchor
585/// - `focus_restorer_fallback` — optional fallback focus target on dismiss
586/// - `main_content` — optional content rendered behind the menu
587/// - `menu_content` — optional menu content slot
588///
589/// ## Examples
590///
591/// ```
592/// # use tessera_ui::tessera;
593/// # #[tessera]
594/// # fn component() {
595/// use tessera_components::{
596///     menus::{MenuAnchor, MenuController, MenuPlacement, menu_item, menu_provider},
597///     text::text,
598/// };
599/// use tessera_ui::{Dp, remember};
600/// # use tessera_components::theme::{MaterialTheme, material_theme};
601///
602/// let menu = remember(MenuController::new);
603/// menu.with_mut(MenuController::open);
604/// assert!(menu.with(MenuController::is_open));
605/// # material_theme()
606/// #     .theme(|| MaterialTheme::default())
607/// #     .child(move || {
608/// menu_provider()
609///     .anchor(MenuAnchor::from_dp(
610///         (Dp(16.0), Dp(16.0)),
611///         (Dp(80.0), Dp(40.0)),
612///     ))
613///     .placement(MenuPlacement::BelowStart)
614///     .controller(menu)
615///     .main_content(|| {
616///         text("Main content");
617///     })
618///     .menu_content(|menu| {
619///         menu_item().menu(menu).label("Edit").on_click(|| {});
620///     });
621/// # });
622/// # }
623/// # tessera_ui::testing::layout_test(component).run();
624/// ```
625#[tessera]
626pub fn menu_provider(
627    anchor: Option<MenuAnchor>,
628    window_anchor: Option<State<tessera_ui::LayoutCoordinates>>,
629    grouped: Option<bool>,
630    color_style: Option<MenuColorStyle>,
631    placement: Option<MenuPlacement>,
632    offset: Option<[Dp; 2]>,
633    modifier: Option<Modifier>,
634    max_height: Option<Px>,
635    shape: Option<Shape>,
636    elevation: Option<Dp>,
637    container_color: Option<Color>,
638    scrim_color: Option<Color>,
639    close_on_background: Option<bool>,
640    close_on_escape: Option<bool>,
641    on_dismiss: Option<Callback>,
642    is_open: Option<bool>,
643    controller: Option<State<MenuController>>,
644    focus_restorer_fallback: Option<FocusRequester>,
645    main_content: Option<RenderSlot>,
646    menu_content: Option<RenderSlotWith<MenuScope>>,
647) {
648    let controlled_is_open = is_open;
649    let is_open = is_open.unwrap_or(false);
650    let grouped = grouped.unwrap_or(false);
651    let inherited_style = use_context::<MenuColorStyle>().map(|style| style.get());
652    let color_style = color_style.or(inherited_style).unwrap_or_default();
653    let scheme = use_context::<MaterialTheme>()
654        .expect("MaterialTheme must be provided")
655        .get()
656        .color_scheme;
657    let mut provider_args = MenuProviderConfig {
658        host: None,
659        anchor,
660        window_anchor,
661        bounds: None,
662        focus_scope: None,
663        parent_request: None,
664        grouped,
665        color_style,
666        placement: placement.unwrap_or_default(),
667        offset: offset.unwrap_or([Dp(0.0), MENU_VERTICAL_GAP]),
668        modifier: modifier.unwrap_or_else(default_menu_modifier),
669        max_height: max_height.or_else(default_max_height),
670        animate_shape: shape.is_none(),
671        shape: shape.unwrap_or_else(default_menu_shape),
672        elevation: elevation.unwrap_or(Dp(3.0)),
673        container_color: container_color.unwrap_or_else(|| {
674            if grouped {
675                Color::TRANSPARENT
676            } else {
677                color_style.container(&scheme)
678            }
679        }),
680        scrim_color: scrim_color.unwrap_or_else(default_scrim_color),
681        close_on_background: close_on_background.unwrap_or(true),
682        close_on_escape: close_on_escape.unwrap_or(true),
683        on_dismiss,
684        is_open,
685        controller,
686        focus_restorer_fallback,
687        main_content,
688        menu_content,
689    };
690    let controller = if let Some(controller) = provider_args.controller {
691        controller
692    } else {
693        remember(MenuController::new)
694    };
695
696    // Only a controlled `is_open` prop drives the controller. When the caller
697    // manages visibility through the `controller` (the common case), leave the
698    // controller untouched so `open_at`/`open`/`close` are not overwritten.
699    if let Some(target) = controlled_is_open
700        && controller.with(|c| c.is_open()) != target
701    {
702        controller.with_mut(|c| {
703            if target {
704                c.open();
705            } else {
706                c.close();
707            }
708        });
709    }
710
711    provider_args.controller = Some(controller);
712    menu_host().provider(provider_args);
713}
714
715#[tessera]
716fn menu_provider_content(provider: Option<MenuProviderConfig>) {
717    let provider_args = provider.unwrap_or_default();
718    let controller = provider_args.controller.expect("menu controller");
719    let host = provider_args.host.expect("menu host");
720
721    let main_content = provider_args.main_content.unwrap_or_else(RenderSlot::empty);
722    let menu_content = if let Some(content) = provider_args.menu_content {
723        content
724    } else {
725        RenderSlotWith::new(|_| {})
726    };
727    let menu_open_state = remember(|| false);
728    let main_focus_scope = remember(FocusScopeNode::new).get();
729    let internal_focus_scope = remember(FocusScopeNode::new).get();
730    let menu_focus_scope = provider_args.focus_scope.unwrap_or(internal_focus_scope);
731    let pending_focus_restore = remember(|| None::<Option<FocusRequester>>);
732
733    let (is_open, anchor) = controller.with(|c| c.snapshot());
734    let anchor =
735        if let Some(anchor) = provider_args.window_anchor {
736            MenuAnchorSource::Window {
737                anchor,
738                origin: host.origin,
739            }
740        } else {
741            MenuAnchorSource::Local(provider_args.anchor.or(anchor).map(|anchor| {
742                tessera_ui::FloatRect::from_position_size(anchor.origin, anchor.size)
743            }))
744        };
745    let mut just_opened = false;
746    let mut just_closed = false;
747    menu_open_state.with_mut(|was_open| {
748        just_opened = !*was_open && is_open;
749        just_closed = *was_open && !is_open;
750        *was_open = is_open;
751    });
752    if just_opened || just_closed {
753        FocusManager::current().defer_keyboard_until_layout();
754    }
755    let root_restore = just_closed
756        && provider_args.parent_request.is_none()
757        && (host.take_root_restore() || host.popup_has_focus());
758    let can_restore =
759        provider_args.parent_request.is_none() || host.root.with(MenuController::is_open);
760    if just_closed && can_restore && (menu_focus_scope.has_focus() || root_restore) {
761        pending_focus_restore.set(Some(provider_args.focus_restorer_fallback));
762    }
763    let internal_bounds = remember(|| None);
764    let bounds = provider_args
765        .bounds
766        .clone()
767        .map(|bounds| bounds.0)
768        .unwrap_or(internal_bounds);
769
770    // Parent pointer handler: block propagation and close on background click.
771    let bounds = bounds;
772    let on_dismiss = provider_args.on_dismiss;
773    let close_on_escape = provider_args.close_on_escape;
774    let close_on_background = provider_args.close_on_background;
775    let parent_request = provider_args.parent_request;
776    let mut modifier = Modifier::new();
777    if pending_focus_restore.get().is_some() {
778        // Restore against the rebuilt tree so a newly disabled trigger cannot
779        // leave a request waiting to steal focus when it is enabled again.
780        modifier = modifier.on_globally_positioned(move |_| {
781            if let Some(fallback) = pending_focus_restore.with_mut(Option::take)
782                && !controller.with(MenuController::is_open)
783            {
784                if let Some(fallback) = fallback {
785                    fallback.request_focus();
786                } else {
787                    main_focus_scope.restore_focus();
788                }
789                if let Some(direction) = controller.with_mut(|menu| menu.exit_direction.take()) {
790                    FocusManager::current().request_move_focus(direction);
791                }
792            }
793        });
794    }
795    if is_open && parent_request.is_none() {
796        modifier = with_pointer_input(modifier, {
797            let bounds = bounds;
798            move |mut input| {
799                let cursor_position = input.cursor_position_rel;
800                let menu_bounds = bounds.get();
801                let should_close_click = close_on_background
802                    && input.has_unconsumed_release()
803                    && is_click_outside_menu(cursor_position, menu_bounds);
804
805                input.block_all();
806
807                if should_close_click {
808                    apply_close_action(controller, &on_dismiss);
809                }
810            }
811        });
812    }
813
814    if is_open && close_on_escape {
815        let on_dismiss = provider_args.on_dismiss;
816        modifier = with_keyboard_input(modifier, move |mut input| {
817            let should_close_escape = input.keyboard_events.iter().any(|event| {
818                event.state == winit::event::ElementState::Pressed
819                    && matches!(
820                        event.physical_key,
821                        winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape)
822                    )
823            });
824            if should_close_escape {
825                apply_close_action(controller, &on_dismiss);
826                input.block_keyboard();
827            }
828        });
829    }
830
831    if parent_request.is_none() {
832        modifier = modifier.push_pointer_input(MenuKeyboardBoundary {
833            controller,
834            rendered_open: is_open,
835        });
836    }
837    modifier = modifier.push_keyboard_input(MenuKeyboardBoundary {
838        controller,
839        rendered_open: is_open,
840    });
841    layout()
842        .modifier(modifier)
843        .policy(MenuLayout {
844            is_open,
845            has_background: parent_request.is_none(),
846            placement: provider_args.placement,
847            offset: provider_args.offset,
848            anchor,
849            bounds: bounds,
850        })
851        .child(move || {
852            layout()
853                .modifier(Modifier::new().focus_scope_with(main_focus_scope))
854                .child_shared(main_content);
855            if is_open {
856                let bounds = bounds;
857                if parent_request.is_none() {
858                    surface()
859                        .style(SurfaceStyle::Filled {
860                            color: provider_args.scrim_color,
861                        })
862                        .modifier(with_pointer_input(
863                            Modifier::new().fill_max_size(),
864                            move |mut input| {
865                                let should_close = close_on_background
866                                    && input.has_unconsumed_release()
867                                    && is_click_outside_menu(
868                                        input.cursor_position_rel,
869                                        bounds.get(),
870                                    );
871                                input.block_all();
872                                if should_close {
873                                    apply_close_action(controller, &on_dismiss);
874                                }
875                            },
876                        ))
877                        .child(|| {});
878                }
879
880                menu_panel()
881                    .provider(provider_args.clone())
882                    .controller(controller)
883                    .focus_scope(menu_focus_scope)
884                    .menu_content_shared(menu_content)
885                    .just_opened(just_opened);
886            }
887        });
888}
889
890fn closes_submenu(
891    is_submenu: bool,
892    placement: MenuPlacement,
893    key: winit::keyboard::NamedKey,
894) -> bool {
895    is_submenu
896        && matches!(
897            (placement, key),
898            (
899                MenuPlacement::RightStart,
900                winit::keyboard::NamedKey::ArrowLeft
901            ) | (
902                MenuPlacement::LeftStart,
903                winit::keyboard::NamedKey::ArrowRight
904            )
905        )
906}
907
908#[tessera]
909fn menu_panel(
910    provider: Option<MenuProviderConfig>,
911    controller: Option<State<MenuController>>,
912    focus_scope: Option<FocusScopeNode>,
913    menu_content: Option<RenderSlotWith<MenuScope>>,
914    just_opened: Option<bool>,
915) {
916    let provider = provider.unwrap_or_default();
917    let just_opened = just_opened.unwrap_or(false);
918    let controller = controller.expect("menu_panel requires controller");
919    let menu_content = menu_content.expect("menu_panel requires menu content");
920    let focus_scope = focus_scope.expect("menu_panel requires focus scope");
921    let on_dismiss = provider.on_dismiss;
922    let placement = provider.placement;
923    let is_submenu = provider.parent_request.is_some();
924    let typeahead = remember(typeahead::Registry::default);
925    let scroll = remember(ScrollableController::new);
926    let color_style = provider.color_style;
927    let grouped = provider.grouped;
928    let host = provider.host.expect("menu host");
929    let frame = current_frame_nanos();
930    let motion = remember(|| MenuShapeMotion::new(MenuPosition::Standalone, frame));
931    let focused = focus_scope.has_focus();
932    if !grouped && provider.animate_shape {
933        if motion.with(|motion| motion.needs_update(MenuPosition::Standalone, focused)) {
934            motion.with_mut(|motion| motion.update(MenuPosition::Standalone, focused, frame));
935        }
936        if !motion.with(MenuShapeMotion::is_idle) {
937            receive_frame_nanos(move |frame| motion::advance(motion, frame));
938        }
939    }
940    let hover = MenuShapeHover {
941        motion,
942        host: Some(host.clone()),
943        controller: Some(controller),
944    };
945
946    let mut modifier = with_keyboard_input(
947        Modifier::new()
948            .layout_coordinate_space()
949            .focus_scope_with(focus_scope)
950            .focus_traversal_policy(FocusTraversalPolicy::vertical().wrap(true)),
951        move |mut input| {
952            if input.key_modifiers.control_key()
953                || input.key_modifiers.alt_key()
954                || input.key_modifiers.super_key()
955            {
956                return;
957            }
958
959            let mut handled = false;
960            for event in input.keyboard_events.iter() {
961                if event.state != winit::event::ElementState::Pressed {
962                    continue;
963                }
964
965                handled = match &event.logical_key {
966                    winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab) => {
967                        host.exit(input.key_modifiers.shift_key());
968                        true
969                    }
970                    winit::keyboard::Key::Named(key)
971                        if closes_submenu(is_submenu, placement, *key) =>
972                    {
973                        apply_close_action(controller, &on_dismiss);
974                        true
975                    }
976                    winit::keyboard::Key::Character(text) => {
977                        typeahead::type_text(typeahead, text.as_str(), current_frame_nanos())
978                    }
979                    _ => false,
980                };
981
982                if handled {
983                    break;
984                }
985            }
986
987            if handled {
988                input.block_keyboard();
989            }
990        },
991    );
992    if just_opened {
993        modifier = modifier.on_globally_positioned(move |_| {
994            if controller.with(MenuController::is_open) {
995                focus_scope.request_focus();
996            }
997        });
998    }
999    if !grouped && provider.animate_shape {
1000        modifier = modifier.push_pointer_preview_input(hover);
1001    }
1002
1003    layout().modifier(modifier).child(move || {
1004        let menu_content = menu_content;
1005        surface()
1006            .style(SurfaceStyle::Filled {
1007                color: provider.container_color,
1008            })
1009            .shape(if grouped {
1010                Shape::RECTANGLE
1011            } else if provider.animate_shape {
1012                motion.with(MenuShapeMotion::shape)
1013            } else {
1014                provider.shape
1015            })
1016            .modifier(provider.modifier.clone().constrain(
1017                None,
1018                Some(AxisConstraint::new(Px::ZERO, provider.max_height)),
1019            ))
1020            .accessibility_role(Role::Menu)
1021            .block_input(true)
1022            .elevation(if grouped {
1023                Dp::ZERO
1024            } else {
1025                provider.elevation
1026            })
1027            .child(move || {
1028                let menu_content = menu_content;
1029                provide_context(
1030                    || color_style,
1031                    move || {
1032                        let inherited = use_context::<MenuColorStyle>().expect("menu colors");
1033                        if inherited.get() != color_style {
1034                            inherited.set(color_style);
1035                        }
1036                        let modifier = Modifier::new().fill_max_width();
1037                        let modifier = if grouped {
1038                            modifier.push_draw(MenuScrollClip { controller: scroll })
1039                        } else {
1040                            modifier.clip_shape(if provider.animate_shape {
1041                                motion.with(MenuShapeMotion::shape)
1042                            } else {
1043                                provider.shape
1044                            })
1045                        };
1046                        column()
1047                            .modifier(modifier.vertical_scroll(scroll).padding_symmetric(
1048                                Dp::ZERO,
1049                                if grouped { Dp::ZERO } else { Dp(2.0) },
1050                            ))
1051                            .cross_axis_alignment(CrossAxisAlignment::Start)
1052                            .children(move || {
1053                                menu_content.render(MenuScope {
1054                                    host,
1055                                    controller,
1056                                    typeahead: typeahead::MenuTypeahead(typeahead),
1057                                });
1058                            });
1059                    },
1060                );
1061            });
1062    });
1063}
1064
1065/// Selection behavior announced for a menu item.
1066#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1067pub enum MenuItemSelectionMode {
1068    /// One checked item in a mutually exclusive set.
1069    #[default]
1070    Radio,
1071    /// An independently checked item in a set of choices.
1072    Checkbox,
1073}
1074
1075#[derive(Clone, PartialEq)]
1076struct MenuItemConfig {
1077    pub menu: Option<MenuScope>,
1078    pub label: String,
1079    pub supporting_text: Option<String>,
1080    pub trailing_text: Option<String>,
1081    pub leading_icon: Option<Painter>,
1082    pub trailing_icon: Option<Painter>,
1083    pub submenu_content: Option<RenderSlotWith<MenuScope>>,
1084    pub submenu_placement: MenuPlacement,
1085    pub selected: bool,
1086    pub selection: Option<Toggled>,
1087    pub selection_mode: Option<MenuItemSelectionMode>,
1088    pub shape: Shape,
1089    pub container_color: Color,
1090    pub enabled: bool,
1091    pub close_on_click: bool,
1092    pub height: Dp,
1093    pub label_color: Color,
1094    pub supporting_color: Color,
1095    pub disabled_color: Color,
1096    pub on_click: Option<Callback>,
1097}
1098
1099impl MenuItemConfig {
1100    pub fn new(label: impl Into<String>) -> Self {
1101        Self {
1102            menu: None,
1103            label: label.into(),
1104            supporting_text: None,
1105            trailing_text: None,
1106            leading_icon: None,
1107            trailing_icon: None,
1108            submenu_content: None,
1109            submenu_placement: MenuPlacement::RightStart,
1110            selected: false,
1111            selection: None,
1112            selection_mode: None,
1113            shape: MenuDefaults::item_shape(MenuPosition::Standalone, false),
1114            container_color: Color::TRANSPARENT,
1115            enabled: true,
1116            close_on_click: true,
1117            height: MENU_ITEM_HEIGHT,
1118            label_color: Color::new(0.1, 0.1, 0.1, 1.0),
1119            supporting_color: Color::new(0.4, 0.4, 0.4, 1.0),
1120            disabled_color: Color::new(0.1, 0.1, 0.1, 1.0)
1121                .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1122            on_click: None,
1123        }
1124    }
1125}
1126
1127impl Default for MenuItemConfig {
1128    fn default() -> Self {
1129        Self::new("")
1130    }
1131}
1132
1133/// # menu_item
1134///
1135/// Material Design 3 menu item for use inside [`menu_provider`].
1136///
1137/// ## Usage
1138///
1139/// Build contextual or overflow menu actions inside
1140/// `menu_provider().menu_content(...)`.
1141///
1142/// ## Parameters
1143///
1144/// - `menu` — action owner received from the enclosing menu content slot.
1145/// - `label` — primary text shown for the menu action
1146/// - `supporting_text` — optional supporting text below the label
1147/// - `trailing_text` — optional trailing text such as a shortcut hint
1148/// - `leading_icon` — optional icon shown on the leading side
1149/// - `trailing_icon` — optional trailing icon before the submenu disclosure
1150///   arrow
1151/// - `submenu_content` — optional submenu opened by activation or mouse dwell;
1152///   branch activation opens this submenu instead of calling `on_click`.
1153/// - `submenu_placement` — optional submenu placement override
1154/// - `selected` — caller-owned visual and checked state; activation never
1155///   changes it automatically.
1156/// - `selection_mode` — optional radio or checkbox semantics for a leaf or a
1157///   submenu branch; omit it for an ordinary action.
1158/// - `position` — item position within its group, controlling corner shapes
1159/// - `shape` — optional fixed item shape override
1160/// - `container_color` — optional item background override
1161/// - `color_style` — optional standard or vibrant color override
1162/// - `enabled` — whether the item can be activated
1163/// - `close_on_click` — whether selecting a leaf closes the menu chain; submenu
1164///   expansion does not close the chain.
1165/// - `height` — optional item height override
1166/// - `label_color` — optional label text color override
1167/// - `supporting_color` — optional supporting or trailing text color override
1168/// - `disabled_color` — optional disabled content color override
1169/// - `on_click` — optional leaf activation callback; `submenu_content` takes
1170///   precedence when both are provided.
1171///
1172/// ## Examples
1173/// ```
1174/// # use tessera_ui::tessera;
1175/// # #[tessera]
1176/// # fn component() {
1177/// use tessera_components::{
1178///     menus::{menu_item, menu_provider},
1179///     text::text,
1180/// };
1181/// # use tessera_components::theme::{MaterialTheme, material_theme};
1182///
1183/// # material_theme()
1184/// #     .theme(|| MaterialTheme::default())
1185/// #     .child(|| {
1186/// menu_provider()
1187///     .is_open(true)
1188///     .menu_content(|menu| {
1189///         menu_item().menu(menu).label("Open");
1190///     })
1191///     .main_content(|| {
1192///         text("Main content");
1193///     });
1194/// # });
1195/// # }
1196/// # tessera_ui::testing::layout_test(component).run();
1197/// ```
1198#[tessera]
1199pub fn menu_item(
1200    menu: Option<MenuScope>,
1201    #[prop(into)] label: Option<String>,
1202    #[prop(into)] supporting_text: Option<String>,
1203    #[prop(into)] trailing_text: Option<String>,
1204    #[prop(into)] leading_icon: Option<Painter>,
1205    #[prop(into)] trailing_icon: Option<Painter>,
1206    submenu_content: Option<RenderSlotWith<MenuScope>>,
1207    submenu_placement: Option<MenuPlacement>,
1208    selected: Option<bool>,
1209    selection_mode: Option<MenuItemSelectionMode>,
1210    position: Option<MenuPosition>,
1211    shape: Option<Shape>,
1212    container_color: Option<Color>,
1213    color_style: Option<MenuColorStyle>,
1214    enabled: Option<bool>,
1215    close_on_click: Option<bool>,
1216    height: Option<Dp>,
1217    label_color: Option<Color>,
1218    supporting_color: Option<Color>,
1219    disabled_color: Option<Color>,
1220    on_click: Option<Callback>,
1221) {
1222    let label = label.unwrap_or_default();
1223    let selected = selected.unwrap_or(false);
1224    let selection = selection_mode.map(|_| {
1225        if selected {
1226            Toggled::True
1227        } else {
1228            Toggled::False
1229        }
1230    });
1231    let position = position.unwrap_or_default();
1232    let enabled = enabled.unwrap_or(true) && (on_click.is_some() || submenu_content.is_some());
1233    let inherited_style = use_context::<MenuColorStyle>().map(|style| style.get());
1234    let color_style = color_style.or(inherited_style).unwrap_or_default();
1235    let scheme = use_context::<MaterialTheme>()
1236        .expect("MaterialTheme must be provided")
1237        .get()
1238        .color_scheme;
1239
1240    let mut args = MenuItemConfig {
1241        menu,
1242        label,
1243        supporting_text,
1244        trailing_text,
1245        leading_icon,
1246        trailing_icon,
1247        submenu_content,
1248        submenu_placement: submenu_placement.unwrap_or(MenuPlacement::RightStart),
1249        selected,
1250        selection_mode,
1251        selection,
1252        shape: shape.unwrap_or_else(|| MenuDefaults::item_shape(position, selected)),
1253        container_color: container_color.unwrap_or_else(|| {
1254            if selected && enabled {
1255                color_style.selected_container(&scheme)
1256            } else {
1257                Color::TRANSPARENT
1258            }
1259        }),
1260        enabled,
1261        close_on_click: close_on_click.unwrap_or(true),
1262        height: height.unwrap_or(MENU_ITEM_HEIGHT),
1263        label_color: label_color.unwrap_or_else(|| color_style.content(&scheme, selected)),
1264        supporting_color: supporting_color
1265            .unwrap_or_else(|| color_style.supporting(&scheme, selected)),
1266        disabled_color: disabled_color.unwrap_or(
1267            color_style
1268                .content(&scheme, false)
1269                .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1270        ),
1271        on_click,
1272    };
1273
1274    if args.close_on_click
1275        && args.on_click.is_some()
1276        && args.submenu_content.is_none()
1277        && let Some(menu) = args.menu
1278    {
1279        let host = menu.host;
1280        let previous = args.on_click;
1281        args.on_click = Some(Callback::new(move || {
1282            if let Some(callback) = previous {
1283                callback.call();
1284            }
1285            host.close_all();
1286        }));
1287    }
1288
1289    menu_item_inner().args(args);
1290}
1291
1292#[tessera]
1293fn menu_item_surface(
1294    item: Option<MenuItemConfig>,
1295    submenu_controller: Option<State<MenuController>>,
1296    submenu_request: Option<State<MenuPopupRequest>>,
1297    focus_requester: Option<FocusRequester>,
1298) {
1299    let item = item.unwrap_or_default();
1300    let enabled = item.enabled && (item.on_click.is_some() || item.submenu_content.is_some());
1301    let has_submenu = item.submenu_content.is_some();
1302    let focus_requester = if let Some(requester) = focus_requester {
1303        requester
1304    } else {
1305        remember(FocusRequester::new).get()
1306    };
1307    let typeahead = item.menu.map(|menu| menu.typeahead);
1308    let _typeahead_lease =
1309        remember(|| typeahead.map(|context| typeahead::ItemLease::new(context.0, focus_requester)));
1310    if let Some(context) = typeahead {
1311        typeahead::update(context.0, focus_requester, &item.label, enabled);
1312    }
1313    let hover = remember(MenuHoverIntent::default);
1314    let host = item.menu.map(|menu| menu.host);
1315    let parent = item.menu.map(|menu| menu.controller);
1316    if enabled && hover.with(|hover| hover.deadline.is_some()) {
1317        let host = host.clone();
1318        receive_frame_nanos(move |frame| {
1319            let Some(deadline) = hover.with(|hover| hover.deadline) else {
1320                return FrameNanosControl::Stop;
1321            };
1322            if !hover.with(|hover| hover.inside)
1323                || !focus_requester.is_focused()
1324                || parent.is_none_or(|parent| !parent.with(MenuController::is_open))
1325            {
1326                hover.with_mut(|hover| hover.deadline = None);
1327                return FrameNanosControl::Stop;
1328            }
1329            if frame < deadline {
1330                return FrameNanosControl::Continue;
1331            }
1332            hover.with_mut(|hover| hover.deadline = None);
1333            if let Some((host, parent)) = host.as_ref().zip(parent) {
1334                host.apply_hover(parent, submenu_request);
1335            }
1336            FrameNanosControl::Stop
1337        });
1338    }
1339    let modifier = if let Some(request) = submenu_request {
1340        with_keyboard_input(Modifier::new(), move |mut input| {
1341            if input.key_modifiers.control_key()
1342                || input.key_modifiers.alt_key()
1343                || input.key_modifiers.super_key()
1344            {
1345                return;
1346            }
1347            let value = request.get();
1348            if !value.enabled {
1349                return;
1350            }
1351            let opening = if value.placement == MenuPlacement::LeftStart {
1352                winit::keyboard::NamedKey::ArrowLeft
1353            } else {
1354                winit::keyboard::NamedKey::ArrowRight
1355            };
1356            let should_open = input.keyboard_events.iter().any(|event| {
1357                event.state == winit::event::ElementState::Pressed
1358                    && event.logical_key == winit::keyboard::Key::Named(opening)
1359            });
1360            if should_open {
1361                MenuPopupRequest::open(request);
1362                input.block_keyboard();
1363            }
1364        })
1365        .on_layout_coordinates(move |bounds| {
1366            let anchor = request.get().anchor;
1367            if anchor.get() != bounds {
1368                anchor.set(bounds);
1369            }
1370        })
1371    } else {
1372        Modifier::new()
1373    };
1374
1375    let modifier = if let Some(context) = typeahead {
1376        modifier.on_layout_rect_in_space(move |bounds| {
1377            typeahead::position(context.0, focus_requester, bounds);
1378        })
1379    } else {
1380        modifier
1381    };
1382    layout().modifier(modifier).child(move || {
1383        let on_click = if has_submenu {
1384            let request = submenu_request.expect("submenu request");
1385            Some(Callback::new(move || {
1386                MenuPopupRequest::open(request);
1387            }))
1388        } else {
1389            item.on_click
1390        };
1391        let theme = use_context::<MaterialTheme>()
1392            .expect("MaterialTheme must be provided")
1393            .get();
1394        let label_color = if enabled {
1395            item.label_color
1396        } else {
1397            item.disabled_color
1398        };
1399        let supporting_color = if enabled {
1400            item.supporting_color
1401        } else {
1402            item.disabled_color
1403        };
1404        let selection = item.selection;
1405        let expanded =
1406            submenu_controller.map(|controller| controller.with(MenuController::is_open));
1407        surface()
1408            .style(SurfaceStyle::Filled {
1409                color: item.container_color,
1410            })
1411            .shape(item.shape)
1412            .enabled(enabled)
1413            .modifier(
1414                Modifier::new()
1415                    .fill_max_width()
1416                    .padding_symmetric(Dp(4.0), Dp::ZERO)
1417                    .minimum_interactive_component_size()
1418                    .push_pointer_preview_input(MenuItemHoverFocus {
1419                        enabled,
1420                        requester: focus_requester,
1421                        host: host.clone(),
1422                        controller: parent,
1423                        request: submenu_request,
1424                        hover,
1425                    })
1426                    .push_semantics(MenuItemDisclosure(expanded)),
1427            )
1428            .accessibility_role(match item.selection_mode {
1429                Some(MenuItemSelectionMode::Radio) => Role::MenuItemRadio,
1430                Some(MenuItemSelectionMode::Checkbox) => Role::MenuItemCheckBox,
1431                None => Role::MenuItem,
1432            })
1433            .accessibility_toggled_optional(selection)
1434            .accessibility_label(item.label.clone())
1435            .block_input(true)
1436            .ripple_color(label_color)
1437            .focus_requester(focus_requester)
1438            .on_click_optional(on_click)
1439            .accessibility_description_optional(item.supporting_text.clone())
1440            .child({
1441                let item = item.clone();
1442                move || {
1443                    let item = item.clone();
1444                    row()
1445                        .modifier(
1446                            Modifier::new()
1447                                .fill_max_width()
1448                                .constrain(
1449                                    None,
1450                                    Some(AxisConstraint::at_least(Px::from(item.height))),
1451                                )
1452                                .padding_symmetric(MENU_HORIZONTAL_PADDING, Dp(8.0)),
1453                        )
1454                        .cross_axis_alignment(CrossAxisAlignment::Center)
1455                        .children(move || {
1456                            if item.selected {
1457                                checkmark()
1458                                    .color(label_color)
1459                                    .size(MENU_LEADING_SIZE)
1460                                    .padding([2.0, 2.0]);
1461                                spacer().modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1462                            } else if let Some(painter) = item.leading_icon.clone() {
1463                                icon()
1464                                    .painter(painter)
1465                                    .size(MENU_LEADING_SIZE)
1466                                    .tint(supporting_color);
1467                                spacer().modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1468                            }
1469                            let label = item.label.clone();
1470                            let supporting = item.supporting_text.clone();
1471                            column()
1472                                .modifier(Modifier::new().weight(1.0))
1473                                .cross_axis_alignment(CrossAxisAlignment::Start)
1474                                .children(move || {
1475                                    text(label.clone())
1476                                        .style(theme.typography.body_large)
1477                                        .color(label_color);
1478                                    if let Some(supporting) = supporting.as_ref() {
1479                                        text(supporting.clone())
1480                                            .style(theme.typography.body_medium)
1481                                            .color(supporting_color);
1482                                    }
1483                                });
1484                            if item.trailing_icon.is_some()
1485                                || item.trailing_text.is_some()
1486                                || has_submenu
1487                            {
1488                                spacer().modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1489                                if let Some(painter) = item.trailing_icon.clone() {
1490                                    icon()
1491                                        .painter(painter)
1492                                        .size(MENU_LEADING_SIZE)
1493                                        .tint(supporting_color);
1494                                } else if let Some(trailing) = item.trailing_text.clone() {
1495                                    text(trailing)
1496                                        .style(theme.typography.label_small)
1497                                        .color(supporting_color);
1498                                }
1499                                if has_submenu {
1500                                    if item.trailing_icon.is_some() || item.trailing_text.is_some()
1501                                    {
1502                                        spacer()
1503                                            .modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1504                                    }
1505                                    icon()
1506                                        .painter(
1507                                            if item.submenu_placement == MenuPlacement::LeftStart {
1508                                                filled::CHEVRON_LEFT_SVG
1509                                            } else {
1510                                                filled::CHEVRON_RIGHT_SVG
1511                                            },
1512                                        )
1513                                        .size(MENU_LEADING_SIZE)
1514                                        .tint(supporting_color);
1515                                }
1516                            }
1517                        });
1518                }
1519            });
1520    });
1521}
1522
1523struct MenuItemHoverFocus {
1524    enabled: bool,
1525    requester: FocusRequester,
1526    host: Option<MenuHost>,
1527    controller: Option<State<MenuController>>,
1528    request: Option<State<MenuPopupRequest>>,
1529    hover: State<MenuHoverIntent>,
1530}
1531
1532#[derive(Default)]
1533struct MenuHoverIntent {
1534    inside: bool,
1535    mouse: bool,
1536    deadline: Option<u64>,
1537}
1538
1539impl PointerInputModifierNode for MenuItemHoverFocus {
1540    fn on_pointer_input(&self, input: PointerInput<'_>) {
1541        let moved = input.pointer_changes.iter().any(|change| {
1542            change.pointer_id == MOUSE_POINTER_ID
1543                && matches!(change.content, CursorEventContent::Moved(_))
1544        });
1545        let mouse = input
1546            .pointer_changes
1547            .last()
1548            .map(|change| change.pointer_id == MOUSE_POINTER_ID)
1549            .unwrap_or_else(|| self.hover.with(|hover| hover.mouse));
1550        let inside = self.enabled
1551            && mouse
1552            && input.cursor_position_rel.is_some_and(|point| {
1553                point.x >= 0.0
1554                    && point.y >= 0.0
1555                    && point.x < input.computed_data.width.to_f32()
1556                    && point.y < input.computed_data.height.to_f32()
1557            })
1558            && self
1559                .host
1560                .as_ref()
1561                .zip(self.controller)
1562                .is_none_or(|(host, controller)| {
1563                    host.allows_hover(controller, input.cursor_position_abs())
1564                });
1565        let schedule = inside
1566            && moved
1567            && self.hover.with(|hover| hover.deadline.is_none())
1568            && self
1569                .host
1570                .as_ref()
1571                .zip(self.controller)
1572                .is_some_and(|(host, parent)| {
1573                    if let Some(request) = self.request {
1574                        !request.get().controller.with(MenuController::is_open)
1575                    } else {
1576                        host.has_descendants(parent)
1577                    }
1578                });
1579        if schedule
1580            || self
1581                .hover
1582                .with(|hover| hover.inside != inside || hover.mouse != mouse)
1583        {
1584            self.hover.with_mut(|hover| {
1585                hover.inside = inside;
1586                hover.mouse = mouse;
1587                if !inside {
1588                    hover.deadline = None;
1589                } else if schedule {
1590                    hover.deadline =
1591                        Some(current_frame_nanos().saturating_add(MENU_HOVER_DELAY_NANOS));
1592                }
1593            });
1594        }
1595        if inside && moved {
1596            self.requester.request_focus();
1597        }
1598    }
1599}
1600
1601struct MenuItemDisclosure(Option<bool>);
1602
1603impl tessera_ui::SemanticsModifierNode for MenuItemDisclosure {
1604    fn apply(
1605        &self,
1606        node: &mut tessera_ui::AccessibilityNode,
1607        _handler: &mut Option<tessera_ui::AccessibilityActionHandler>,
1608    ) {
1609        node.expanded = self.0;
1610        node.has_popup = self.0.map(|_| HasPopup::Menu);
1611    }
1612}
1613
1614#[tessera]
1615fn menu_item_inner(args: Option<MenuItemConfig>) {
1616    let args = args.unwrap_or_default();
1617    let submenu_content = args.submenu_content;
1618
1619    if let Some(submenu_content) = submenu_content {
1620        let submenu_controller = remember(MenuController::new);
1621        let focus_requester = remember(FocusRequester::new).get();
1622        let menu = args.menu.expect("submenu items require a menu scope");
1623        let host = menu.host;
1624        let parent = menu.controller;
1625        let value = MenuPopupRequest {
1626            host: host.clone(),
1627            parent,
1628            controller: submenu_controller,
1629            anchor: remember(tessera_ui::LayoutCoordinates::default),
1630            scope: remember(FocusScopeNode::new).get(),
1631            fallback: focus_requester,
1632            content: submenu_content,
1633            placement: args.submenu_placement,
1634            color_style: use_context::<MenuColorStyle>()
1635                .map(|style| style.get())
1636                .unwrap_or_default(),
1637            enabled: args.enabled,
1638            bounds: remember(|| None),
1639        };
1640        let request = remember(|| value.clone());
1641        if request.get() != value {
1642            request.set(value);
1643        }
1644        let scope = request.get().scope;
1645        let _lease = remember(|| MenuPopupLease {
1646            host,
1647            request,
1648            parent,
1649            scope,
1650        });
1651        if !args.enabled {
1652            request.get().host.close_disabled_branch(request);
1653        }
1654        menu_item_surface()
1655            .item(args)
1656            .submenu_controller(submenu_controller)
1657            .submenu_request(request)
1658            .focus_requester(focus_requester);
1659    } else {
1660        menu_item_surface().item(args);
1661    }
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666    use tessera_ui::testing::{
1667        layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
1668        start_layout_test_session,
1669    };
1670    use tessera_ui::{
1671        CursorEventContent, Dp, FocusModifierExt as _, GestureState, LayoutCoordinates, Modifier,
1672        PointerChange, PressKeyEventType, Px, PxPosition, State, remember, scaled_box, tessera,
1673        testing::LayoutSnapshot,
1674    };
1675
1676    use crate::{
1677        button::button,
1678        column::column,
1679        modifier::{ModifierExt as _, SemanticsArgs},
1680        spacer::spacer,
1681        theme::material_theme,
1682    };
1683
1684    use super::{MenuAnchor, MenuController, menu_item, menu_provider};
1685
1686    #[derive(Clone, Copy, Default)]
1687    struct FixtureHandles {
1688        typeahead: Option<State<super::typeahead::Registry>>,
1689        child_typeahead: Option<State<super::typeahead::Registry>>,
1690        remove_delete: Option<State<bool>>,
1691        controller: Option<State<MenuController>>,
1692        main_state: Option<State<u32>>,
1693    }
1694
1695    #[tessera]
1696    fn typeahead_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1697        let controller = remember(|| {
1698            let mut controller = MenuController::new();
1699            controller.open();
1700            controller
1701        });
1702        let remove_delete = remember(|| false);
1703        observed.with_mut(|handles| handles.controller = Some(controller));
1704        observed.with_mut(|handles| handles.remove_delete = Some(remove_delete));
1705        material_theme().child(move || {
1706            let observed = observed.clone();
1707            menu_provider()
1708                .controller(controller)
1709                .main_content(|| {
1710                    crate::spacer::spacer().modifier(Modifier::new().fill_max_size());
1711                })
1712                .menu_content(move |menu| {
1713                    let registry = menu.typeahead.0;
1714                    observed.with_mut(|handles| handles.typeahead = Some(registry));
1715                    menu_item().menu(menu).label("Preview").on_click(|| {});
1716                    menu_item().menu(menu).label("Share").submenu_content({
1717                        let observed = observed.clone();
1718                        move |menu| {
1719                            let registry = menu.typeahead.0;
1720                            observed.with_mut(|handles| handles.child_typeahead = Some(registry));
1721                            menu_item().menu(menu).label("Copy link").on_click(|| {});
1722                            menu_item().menu(menu).label("Send").on_click(|| {});
1723                        }
1724                    });
1725                    if !remove_delete.get() {
1726                        menu_item().menu(menu).label("Delete").on_click(|| {});
1727                    }
1728                    menu_item().menu(menu).label("Duplicate").on_click(|| {});
1729                    menu_item()
1730                        .menu(menu)
1731                        .label("Download")
1732                        .enabled(false)
1733                        .on_click(|| {});
1734                });
1735        });
1736    }
1737
1738    #[cfg(not(target_family = "wasm"))]
1739    #[test]
1740    #[ignore = "requires a native GPU adapter"]
1741    fn gpu_menu_prefix_matching_moves_focus_skips_disabled_items_and_cleans_removed_items() {
1742        use tessera_ui::{
1743            HeadlessConfig, HeadlessRenderer,
1744            accesskit::{Action, ActionRequest, Role, TreeId},
1745        };
1746
1747        let observed = tessera_ui::testing::TestProbe::default();
1748        let mut renderer = HeadlessRenderer::new(
1749            Box::new({
1750                let observed = observed.clone();
1751                move || {
1752                    typeahead_fixture(observed.clone());
1753                }
1754            }),
1755            vec![Box::new(crate::TesseraComponents)],
1756            HeadlessConfig {
1757                width: 600,
1758                height: 400,
1759                sample_count: 1,
1760                ..Default::default()
1761            },
1762        )
1763        .unwrap();
1764        renderer.render_frames(6);
1765        let registry = observed.get().typeahead.unwrap();
1766        let focused = |renderer: &HeadlessRenderer| {
1767            let tree = renderer.accessibility_snapshot().unwrap();
1768            tree.nodes
1769                .iter()
1770                .find(|(id, _)| *id == tree.focus)
1771                .and_then(|(_, node)| node.label())
1772                .map(str::to_owned)
1773        };
1774        assert_eq!(focused(&renderer).as_deref(), Some("Preview"));
1775        for (text, time, expected) in [
1776            ("d", 0, "Delete"),
1777            ("D", 100_000_000, "Duplicate"),
1778            ("e", 200_000_000, "Delete"),
1779            ("du", 2_000_000_000, "Duplicate"),
1780            ("do", 4_000_000_000, "Duplicate"),
1781            ("d", 6_000_000_000, "Delete"),
1782        ] {
1783            assert!(super::typeahead::type_text(registry, text, time));
1784            renderer.render_frames(2);
1785            assert_eq!(
1786                focused(&renderer).as_deref(),
1787                Some(expected),
1788                "prefix {text}"
1789            );
1790        }
1791        observed.get().remove_delete.unwrap().set(true);
1792        renderer.render_frames(3);
1793        super::typeahead::type_text(registry, "d", 8_000_000_000);
1794        renderer.render_frames(2);
1795        assert_eq!(focused(&renderer).as_deref(), Some("Duplicate"));
1796        let tree = renderer.accessibility_snapshot().unwrap();
1797        let share = tree
1798            .nodes
1799            .iter()
1800            .find(|(_, node)| node.role() == Role::MenuItem && node.label() == Some("Share"))
1801            .unwrap()
1802            .0;
1803        assert!(renderer.dispatch_accessibility_action(ActionRequest {
1804            action: Action::Click,
1805            target_node: share,
1806            target_tree: TreeId::ROOT,
1807            data: None,
1808        }));
1809        renderer.render_frames(6);
1810        let child = observed.get().child_typeahead.unwrap();
1811        super::typeahead::type_text(child, "s", 0);
1812        renderer.render_frames(2);
1813        assert_eq!(focused(&renderer).as_deref(), Some("Send"));
1814        observed
1815            .get()
1816            .controller
1817            .unwrap()
1818            .with_mut(MenuController::close);
1819        renderer.render_frames(4);
1820        assert!(!registry.is_alive());
1821        assert!(!child.is_alive());
1822    }
1823
1824    #[tessera]
1825    fn menu_controller_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1826        let controller = remember(MenuController::new);
1827        observed.with_mut(|handles| handles.controller = Some(controller));
1828        material_theme().child(move || {
1829            menu_provider()
1830                .controller(controller)
1831                .main_content({
1832                    let observed = observed.clone();
1833                    move || {
1834                        let state = remember(|| 0_u32);
1835                        observed.with_mut(|handles| handles.main_state = Some(state));
1836                        column().children(|| {});
1837                    }
1838                })
1839                .menu_content(|menu| {
1840                    column()
1841                        .modifier(Modifier::new().semantics(SemanticsArgs {
1842                            test_tag: Some("menu_content_tag".to_string()),
1843                            ..Default::default()
1844                        }))
1845                        .children(move || {
1846                            menu_item().menu(menu).label("Edit");
1847                        });
1848                });
1849        });
1850    }
1851
1852    #[tessera]
1853    fn menu_background_fixture(
1854        close_on_background: bool,
1855        observed: tessera_ui::testing::TestProbe<FixtureHandles>,
1856    ) {
1857        let controller = remember(MenuController::new);
1858        let state = remember(|| 0_u32);
1859        observed.with_mut(|handles| handles.controller = Some(controller));
1860        observed.with_mut(|handles| handles.main_state = Some(state));
1861        material_theme().child(move || {
1862            menu_provider()
1863                .controller(controller)
1864                .close_on_background(close_on_background)
1865                .main_content(move || {
1866                    button()
1867                        .modifier(Modifier::new().fill_max_size())
1868                        .on_click(move || state.with_mut(|count| *count += 1));
1869                })
1870                .menu_content(|menu| {
1871                    column()
1872                        .modifier(Modifier::new().semantics(SemanticsArgs {
1873                            test_tag: Some("menu_background_content".to_string()),
1874                            ..Default::default()
1875                        }))
1876                        .children(move || {
1877                            menu_item().menu(menu).label("Edit").enabled(false);
1878                        });
1879                });
1880        });
1881    }
1882
1883    #[test]
1884    fn menu_background_dismisses_without_activating_underlying_content() {
1885        for (close_on_background, click_inside, expected_open) in [
1886            (true, false, false),
1887            (false, false, true),
1888            (true, true, true),
1889        ] {
1890            let observed = tessera_ui::testing::TestProbe::default();
1891            let mut session = start_layout_test_session(
1892                layout_test({
1893                    let observed = observed.clone();
1894                    move || {
1895                        menu_background_fixture(close_on_background, observed.clone());
1896                    }
1897                })
1898                .viewport_px(400, 300),
1899            );
1900            let _ = pump_layout_test_session(&mut session, 0);
1901            let controller = observed.get().controller.expect("controller");
1902            controller.with_mut(|menu| {
1903                menu.open_at(MenuAnchor::at(PxPosition::new(Px(10), Px(10))));
1904            });
1905            let opened = pump_layout_test_session(&mut session, 16_666_667);
1906            let bounds = opened.node("menu_background_content").bounds();
1907            let now = tessera_ui::time::Instant::now();
1908            let cursor = Some(if click_inside {
1909                PxPosition::new(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
1910            } else {
1911                PxPosition::new(Px(350), Px(250))
1912            });
1913            for (index, content) in [
1914                CursorEventContent::Pressed(PressKeyEventType::Left),
1915                CursorEventContent::Released(PressKeyEventType::Left),
1916            ]
1917            .into_iter()
1918            .enumerate()
1919            {
1920                let change = PointerChange::new(now, 1, content, GestureState::TapCandidate);
1921                let _ = pump_layout_test_session_with_input(
1922                    &mut session,
1923                    33_333_334 + index as u64 * 16_666_667,
1924                    vec![change],
1925                    cursor,
1926                );
1927            }
1928            assert_eq!(
1929                controller.with(MenuController::is_open),
1930                expected_open,
1931                "close_on_background={close_on_background}, click_inside={click_inside}"
1932            );
1933            assert_eq!(observed.get().main_state.unwrap().get(), 0);
1934        }
1935    }
1936
1937    #[test]
1938    fn menu_provider_renders_menu_items_after_controller_opens() {
1939        let observed = tessera_ui::testing::TestProbe::default();
1940        let mut session = start_layout_test_session(
1941            layout_test({
1942                let observed = observed.clone();
1943                move || menu_controller_fixture(observed.clone())
1944            })
1945            .viewport_px(400, 300),
1946        );
1947        let _ = pump_layout_test_session(&mut session, 0);
1948
1949        let controller = observed.get().controller.expect("controller");
1950        controller.with_mut(|menu| {
1951            menu.open_at(MenuAnchor::at(PxPosition::new(Px(10), Px(10))));
1952        });
1953
1954        let opened: LayoutSnapshot = pump_layout_test_session(&mut session, 16_666_667);
1955        opened.node("menu_content_tag").exists();
1956    }
1957
1958    #[tessera]
1959    fn window_anchor_fixture(observed: tessera_ui::testing::TestProbe<Option<State<Dp>>>) {
1960        let anchor = remember(LayoutCoordinates::default);
1961        let x = remember(|| Dp(40.0));
1962        observed.set(Some(x));
1963        material_theme().child(move || {
1964            tessera_ui::layout::layout()
1965                .modifier(Modifier::new().offset(Dp(12.0), Dp(18.0)))
1966                .child(move || {
1967                    scaled_box(0.5).child(move || {
1968                        menu_provider()
1969                            .window_anchor(anchor)
1970                            .anchor(MenuAnchor::at(PxPosition::new(Px(999), Px(999))))
1971                            .is_open(true)
1972                            .placement(super::MenuPlacement::BelowStart)
1973                            .offset([Dp::ZERO; 2])
1974                            .modifier(Modifier::new().width(Dp(80.0)).semantics(SemanticsArgs {
1975                                test_tag: Some("anchored-menu".into()),
1976                                ..Default::default()
1977                            }))
1978                            .main_content(move || {
1979                                spacer().modifier(
1980                                    Modifier::new()
1981                                        .size(Dp(80.0), Dp(40.0))
1982                                        .offset(x.get(), Dp(30.0))
1983                                        .on_layout_coordinates(move |coordinates| {
1984                                            anchor.set_if_changed(coordinates);
1985                                        }),
1986                                );
1987                            })
1988                            .menu_content(|_| {
1989                                spacer().modifier(Modifier::new().height(Dp(20.0)));
1990                            });
1991                    });
1992                });
1993        });
1994    }
1995
1996    #[test]
1997    fn window_anchor_follows_trigger_geometry_through_ancestor_transforms() {
1998        let observed = tessera_ui::testing::TestProbe::default();
1999        let mut session = start_layout_test_session(
2000            layout_test({
2001                let observed = observed.clone();
2002                move || {
2003                    window_anchor_fixture(observed.clone());
2004                }
2005            })
2006            .viewport_px(400, 300),
2007        );
2008        pump_layout_test_session(&mut session, 0)
2009            .node("anchored-menu")
2010            .position(32, 53);
2011        observed.get().expect("trigger position").set(Dp(100.0));
2012        pump_layout_test_session(&mut session, 16_666_667)
2013            .node("anchored-menu")
2014            .position(62, 53);
2015    }
2016
2017    #[test]
2018    fn menu_visibility_preserves_main_content_state() {
2019        let observed = tessera_ui::testing::TestProbe::default();
2020        let mut session = start_layout_test_session(
2021            layout_test({
2022                let observed = observed.clone();
2023                move || menu_controller_fixture(observed.clone())
2024            })
2025            .viewport_px(400, 300),
2026        );
2027        let _ = pump_layout_test_session(&mut session, 0);
2028        let main_state = observed.get().main_state.expect("main_state");
2029        main_state.set(7);
2030        let controller = observed.get().controller.expect("controller");
2031        controller.with_mut(|menu| {
2032            menu.open_at(MenuAnchor::at(PxPosition::new(Px(10), Px(10))));
2033        });
2034        let _ = pump_layout_test_session(&mut session, 16_666_667);
2035        assert!(observed.get().main_state.unwrap() == main_state);
2036        assert_eq!(main_state.get(), 7);
2037
2038        controller.with_mut(MenuController::close);
2039        let _ = pump_layout_test_session(&mut session, 33_333_334);
2040        assert!(observed.get().main_state.unwrap() == main_state);
2041        assert_eq!(main_state.get(), 7);
2042    }
2043}
2044
2045#[cfg(test)]
2046#[path = "menus/focus_tests.rs"]
2047mod focus_tests;
2048
2049#[cfg(test)]
2050#[path = "menus/appearance_tests.rs"]
2051mod appearance_tests;
2052
2053#[cfg(test)]
2054#[path = "menus/selection_tests.rs"]
2055mod selection_tests;
2056
2057#[cfg(test)]
2058#[path = "menus/submenu_tests.rs"]
2059mod submenu_tests;
2060
2061#[cfg(test)]
2062#[path = "menus/shadow_tests.rs"]
2063mod shadow_tests;
2064
2065#[cfg(test)]
2066#[path = "menus/placement_tests.rs"]
2067mod placement_tests;
2068
2069#[cfg(test)]
2070#[path = "menus/motion_tests.rs"]
2071mod motion_tests;
2072
2073#[cfg(test)]
2074mod navigation_tests {
2075    use tessera_ui::winit::keyboard::NamedKey;
2076
2077    use super::{MenuPlacement, closes_submenu};
2078
2079    #[test]
2080    fn horizontal_close_key_applies_only_to_submenus_and_their_return_direction() {
2081        for (placement, close, open) in [
2082            (
2083                MenuPlacement::RightStart,
2084                NamedKey::ArrowLeft,
2085                NamedKey::ArrowRight,
2086            ),
2087            (
2088                MenuPlacement::LeftStart,
2089                NamedKey::ArrowRight,
2090                NamedKey::ArrowLeft,
2091            ),
2092        ] {
2093            assert!(!closes_submenu(false, placement, close));
2094            assert!(!closes_submenu(false, placement, open));
2095            assert!(closes_submenu(true, placement, close));
2096            assert!(!closes_submenu(true, placement, open));
2097            assert!(!closes_submenu(true, placement, NamedKey::ArrowDown));
2098        }
2099        assert!(!closes_submenu(
2100            true,
2101            MenuPlacement::BelowStart,
2102            NamedKey::ArrowLeft
2103        ));
2104    }
2105}