tessera_ui_basic_components/
menus.rs

1//! Material Design 3 menus for contextual action lists.
2//! ## Usage Present anchored overflow or context actions as surfaced menus.
3use std::sync::Arc;
4
5use closure::closure;
6use derive_builder::Builder;
7use parking_lot::RwLock;
8use tessera_ui::{
9    Color, ComputedData, Constraint, CursorEvent, CursorEventContent, DimensionValue, Dp, Px,
10    PxPosition, PxSize, accesskit::Role, tessera, winit,
11};
12
13use crate::{
14    ShadowProps,
15    alignment::CrossAxisAlignment,
16    checkmark::{CheckmarkArgsBuilder, checkmark},
17    column::{ColumnArgsBuilder, column},
18    material_color::{blend_over, global_material_scheme},
19    pos_misc::is_position_in_rect,
20    ripple_state::RippleState,
21    row::{RowArgsBuilder, row},
22    shape_def::Shape,
23    spacer::{SpacerArgsBuilder, spacer},
24    surface::{SurfaceArgsBuilder, SurfaceStyle, surface},
25    text::{TextArgsBuilder, text},
26};
27
28const MENU_MIN_WIDTH: Dp = Dp(112.0);
29const MENU_MAX_WIDTH: Dp = Dp(280.0);
30const MENU_MAX_HEIGHT: Dp = Dp(320.0);
31const MENU_VERTICAL_GAP: Dp = Dp(4.0);
32const MENU_HORIZONTAL_PADDING: Dp = Dp(16.0);
33const MENU_LEADING_SIZE: Dp = Dp(20.0);
34const MENU_ITEM_HEIGHT: Dp = Dp(48.0);
35const MENU_TRAILING_SPACING: Dp = Dp(16.0);
36
37fn default_menu_width() -> DimensionValue {
38    DimensionValue::Wrap {
39        min: Some(Px::from(MENU_MIN_WIDTH)),
40        max: Some(Px::from(MENU_MAX_WIDTH)),
41    }
42}
43
44fn default_max_height() -> Option<Px> {
45    Some(Px::from(MENU_MAX_HEIGHT))
46}
47
48fn default_menu_shape() -> Shape {
49    Shape::rounded_rectangle(Dp(4.0))
50}
51
52fn default_menu_shadow() -> Option<ShadowProps> {
53    let scheme = global_material_scheme();
54    Some(ShadowProps {
55        color: scheme.shadow.with_alpha(0.12),
56        offset: [0.0, 3.0],
57        smoothness: 8.0,
58    })
59}
60
61fn default_menu_color() -> Color {
62    global_material_scheme().surface
63}
64
65fn default_hover_color() -> Color {
66    let scheme = global_material_scheme();
67    blend_over(scheme.surface, scheme.on_surface, 0.08)
68}
69
70fn default_scrim_color() -> Color {
71    Color::new(0.0, 0.0, 0.0, 0.0)
72}
73
74/// Scope for adding items inside a [`menu`].
75pub struct MenuScope<'a, 'b> {
76    scope: &'a mut crate::column::ColumnScope<'b>,
77}
78
79impl<'a, 'b> MenuScope<'a, 'b> {
80    /// Adds a menu child (typically a [`menu_item`]).
81    pub fn item<F>(&mut self, child: F)
82    where
83        F: FnOnce() + Send + Sync + 'static,
84    {
85        self.scope.child(child);
86    }
87}
88
89/// Describes the anchor rectangle used to position a menu.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct MenuAnchor {
92    /// Top-left corner of the anchor rectangle, relative to the menu container.
93    pub origin: PxPosition,
94    /// Size of the anchor rectangle.
95    pub size: PxSize,
96}
97
98impl MenuAnchor {
99    /// Creates a new anchor rectangle from origin and size.
100    pub fn new(origin: PxPosition, size: PxSize) -> Self {
101        Self { origin, size }
102    }
103
104    /// Creates an anchor positioned at `origin` with zero size.
105    pub fn at(origin: PxPosition) -> Self {
106        Self::new(origin, PxSize::new(Px::ZERO, Px::ZERO))
107    }
108
109    /// Creates an anchor from dp values for origin and size.
110    pub fn from_dp(origin: (Dp, Dp), size: (Dp, Dp)) -> Self {
111        Self::new(
112            PxPosition::new(origin.0.into(), origin.1.into()),
113            PxSize::new(size.0.into(), size.1.into()),
114        )
115    }
116}
117
118impl Default for MenuAnchor {
119    fn default() -> Self {
120        Self::at(PxPosition::new(Px::ZERO, Px::ZERO))
121    }
122}
123
124#[derive(Default)]
125struct MenuStateInner {
126    is_open: bool,
127    anchor: MenuAnchor,
128}
129
130/// Shared state for controlling menu visibility and anchor placement.
131#[derive(Clone, Default)]
132pub struct MenuState {
133    inner: Arc<RwLock<MenuStateInner>>,
134}
135
136impl MenuState {
137    /// Creates a new closed menu state.
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Opens the menu using the previously remembered anchor.
143    pub fn open(&self) {
144        self.inner.write().is_open = true;
145    }
146
147    /// Opens the menu at the provided anchor rectangle.
148    pub fn open_at(&self, anchor: MenuAnchor) {
149        let mut inner = self.inner.write();
150        inner.anchor = anchor;
151        inner.is_open = true;
152    }
153
154    /// Closes the menu.
155    pub fn close(&self) {
156        self.inner.write().is_open = false;
157    }
158
159    /// Toggles the open state, keeping the current anchor.
160    pub fn toggle(&self) {
161        let mut inner = self.inner.write();
162        inner.is_open = !inner.is_open;
163    }
164
165    /// Returns whether the menu is currently open.
166    pub fn is_open(&self) -> bool {
167        self.inner.read().is_open
168    }
169
170    fn snapshot(&self) -> (bool, MenuAnchor) {
171        let inner = self.inner.read();
172        (inner.is_open, inner.anchor)
173    }
174}
175
176/// Controls how the menu is aligned relative to its anchor.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub enum MenuPlacement {
179    /// Align to the anchor's start edge and expand downward.
180    #[default]
181    BelowStart,
182    /// Align to the anchor's end edge and expand downward.
183    BelowEnd,
184    /// Align to the anchor's start edge and expand upward.
185    AboveStart,
186    /// Align to the anchor's end edge and expand upward.
187    AboveEnd,
188}
189
190/// Configuration for the menu overlay/provider.
191#[derive(Builder, Clone)]
192#[builder(pattern = "owned")]
193pub struct MenuProviderArgs {
194    /// How the menu is aligned relative to the provided anchor.
195    #[builder(default)]
196    pub placement: MenuPlacement,
197    /// Additional x/y offset applied after placement relative to the anchor.
198    #[builder(default = "[Dp(0.0), MENU_VERTICAL_GAP]")]
199    pub offset: [Dp; 2],
200    /// Width behavior of the menu container. Defaults to the Material 112–280 dp range.
201    #[builder(default = "default_menu_width()")]
202    pub width: DimensionValue,
203    /// Maximum height of the menu before scrolling is required.
204    #[builder(default = "default_max_height()")]
205    pub max_height: Option<Px>,
206    /// Shape of the menu container.
207    #[builder(default = "default_menu_shape()")]
208    pub shape: Shape,
209    /// Optional shadow representing elevation. Defaults to a soft Material shadow.
210    #[builder(default = "default_menu_shadow()", setter(strip_option))]
211    pub shadow: Option<ShadowProps>,
212    /// Background color of the menu container.
213    #[builder(default = "default_menu_color()")]
214    pub container_color: Color,
215    /// Color of the invisible background layer. Defaults to transparent (menus do not dim content).
216    #[builder(default = "default_scrim_color()")]
217    pub scrim_color: Color,
218    /// Whether a background click should dismiss the menu.
219    #[builder(default = "true")]
220    pub close_on_background: bool,
221    /// Whether pressing Escape dismisses the menu.
222    #[builder(default = "true")]
223    pub close_on_escape: bool,
224    /// Optional callback invoked before the menu closes (background or Escape).
225    #[builder(default, setter(strip_option))]
226    pub on_dismiss: Option<Arc<dyn Fn() + Send + Sync>>,
227}
228
229impl Default for MenuProviderArgs {
230    fn default() -> Self {
231        MenuProviderArgsBuilder::default()
232            .build()
233            .expect("MenuArgsBuilder default build should succeed")
234    }
235}
236
237/// Backward compatibility alias for earlier menu args naming.
238pub type MenuArgs = MenuProviderArgs;
239/// Backward compatibility alias for builder.
240pub type MenuArgsBuilder = MenuProviderArgsBuilder;
241
242#[derive(Clone, Copy)]
243struct MenuBounds {
244    origin: PxPosition,
245    size: ComputedData,
246}
247
248impl Default for MenuBounds {
249    fn default() -> Self {
250        Self {
251            origin: PxPosition::new(Px::ZERO, Px::ZERO),
252            size: ComputedData::ZERO,
253        }
254    }
255}
256
257fn resolve_menu_position(
258    anchor: MenuAnchor,
259    placement: MenuPlacement,
260    menu_size: ComputedData,
261    available: ComputedData,
262    offset: [Dp; 2],
263) -> PxPosition {
264    let anchor_end_x = anchor.origin.x + anchor.size.width;
265    let anchor_end_y = anchor.origin.y + anchor.size.height;
266
267    let mut x = match placement {
268        MenuPlacement::BelowStart | MenuPlacement::AboveStart => anchor.origin.x,
269        MenuPlacement::BelowEnd | MenuPlacement::AboveEnd => anchor_end_x - menu_size.width,
270    };
271
272    let mut y = match placement {
273        MenuPlacement::BelowStart | MenuPlacement::BelowEnd => anchor_end_y,
274        MenuPlacement::AboveStart | MenuPlacement::AboveEnd => anchor.origin.y - menu_size.height,
275    };
276
277    x += Px::from(offset[0]);
278    y += Px::from(offset[1]);
279
280    let max_x = available.width - menu_size.width;
281    let max_y = available.height - menu_size.height;
282    if x < Px::ZERO {
283        x = Px::ZERO;
284    }
285    if y < Px::ZERO {
286        y = Px::ZERO;
287    }
288    if max_x > Px::ZERO {
289        x = x.min(max_x);
290    }
291    if max_y > Px::ZERO {
292        y = y.min(max_y);
293    }
294
295    PxPosition::new(x, y)
296}
297
298fn extract_available_size(constraint: &Constraint) -> ComputedData {
299    let width = match constraint.width {
300        DimensionValue::Fixed(px) => px,
301        DimensionValue::Wrap { max, .. } | DimensionValue::Fill { max, .. } => {
302            max.unwrap_or(Px::MAX)
303        }
304    };
305    let height = match constraint.height {
306        DimensionValue::Fixed(px) => px,
307        DimensionValue::Wrap { max, .. } | DimensionValue::Fill { max, .. } => {
308            max.unwrap_or(Px::MAX)
309        }
310    };
311
312    ComputedData { width, height }
313}
314
315fn should_close_on_click(
316    cursor_events: &[CursorEvent],
317    cursor_position: Option<PxPosition>,
318    bounds: Option<MenuBounds>,
319) -> bool {
320    let Some(bounds) = bounds else {
321        return false;
322    };
323
324    cursor_events.iter().any(|event| {
325        matches!(event.content, CursorEventContent::Released(_))
326            && cursor_position
327                .map(|pos| {
328                    !is_position_in_rect(pos, bounds.origin, bounds.size.width, bounds.size.height)
329                })
330                .unwrap_or(false)
331    })
332}
333
334fn apply_close_action(state: &MenuState, on_dismiss: &Option<Arc<dyn Fn() + Send + Sync>>) {
335    if let Some(callback) = on_dismiss {
336        callback();
337    }
338    state.close();
339}
340
341/// # menu_provider
342///
343/// Provides a Material Design 3 menu overlay anchored to a rectangle.
344///
345/// ## Usage
346///
347/// Wrap page content and show contextual or overflow actions aligned to a trigger element.
348///
349/// ## Parameters
350///
351/// - `args` — configures placement, styling, and dismissal behavior; see [`MenuProviderArgs`].
352/// - `state` — a clonable [`MenuState`] controlling open/close and anchor position.
353/// - `main_content` — closure rendering the underlying page UI.
354/// - `menu_content` — closure that receives a [`MenuScope`] to register menu items.
355///
356/// ## Examples
357///
358/// ```
359/// use std::sync::Arc;
360/// use tessera_ui::Dp;
361/// use tessera_ui_basic_components::{
362///     menus::{
363///         menu_item, menu_provider, MenuAnchor, MenuItemArgsBuilder, MenuPlacement,
364///         MenuProviderArgsBuilder, MenuScope, MenuState,
365///     },
366///     ripple_state::RippleState,
367///     text::text,
368/// };
369///
370/// let state = MenuState::new();
371/// state.open_at(MenuAnchor::from_dp((Dp(8.0), Dp(24.0)), (Dp(120.0), Dp(36.0))));
372/// let state_for_menu = state.clone();
373///
374/// let args = MenuProviderArgsBuilder::default()
375///     .placement(MenuPlacement::BelowStart)
376///     .build()
377///     .unwrap();
378///
379/// menu_provider(
380///     args,
381///     state.clone(),
382///     || {
383///         text("Main content");
384///     },
385///     move |menu_scope: &mut MenuScope<'_, '_>| {
386///         let menu_state = state_for_menu.clone();
387///         menu_scope.item(move || {
388///             menu_item(
389///                 MenuItemArgsBuilder::default()
390///                     .label("Edit")
391///                     .on_click(Arc::new(|| {}))
392///                     .build()
393///                     .unwrap(),
394///                 Some(menu_state.clone()),
395///                 RippleState::new(),
396///             );
397///         });
398///     },
399/// );
400///
401/// assert!(state.is_open());
402/// state.close();
403/// assert!(!state.is_open());
404/// ```
405#[tessera]
406pub fn menu_provider(
407    args: impl Into<MenuProviderArgs>,
408    state: MenuState,
409    main_content: impl FnOnce() + Send + Sync + 'static,
410    menu_content: impl FnOnce(&mut MenuScope<'_, '_>) + Send + Sync + 'static,
411) {
412    let args: MenuProviderArgs = args.into();
413
414    // Render underlying content first.
415    main_content();
416
417    let (is_open, anchor) = state.snapshot();
418    if !is_open {
419        return;
420    }
421
422    // Track menu bounds for outside-click detection.
423    let bounds: Arc<RwLock<Option<MenuBounds>>> = Arc::new(RwLock::new(None));
424
425    // Background layer (non-dimming by default).
426    surface(
427        SurfaceArgsBuilder::default()
428            .style(SurfaceStyle::Filled {
429                color: args.scrim_color,
430            })
431            .width(DimensionValue::FILLED)
432            .height(DimensionValue::FILLED)
433            .block_input(true)
434            .build()
435            .expect("builder construction failed"),
436        None,
437        || {},
438    );
439
440    // Menu panel.
441    surface(
442        {
443            let mut builder = SurfaceArgsBuilder::default()
444                .style(SurfaceStyle::Filled {
445                    color: args.container_color,
446                })
447                .shape(args.shape)
448                .padding(Dp(0.0))
449                .width(args.width)
450                .height(DimensionValue::Wrap {
451                    min: None,
452                    max: args.max_height,
453                })
454                .accessibility_role(Role::Menu)
455                .block_input(true);
456
457            if let Some(shadow) = args.shadow {
458                builder = builder.shadow(shadow);
459            }
460
461            builder.build().expect("builder construction failed")
462        },
463        None,
464        || {
465            column(
466                ColumnArgsBuilder::default()
467                    .width(DimensionValue::FILLED)
468                    .cross_axis_alignment(CrossAxisAlignment::Start)
469                    .build()
470                    .expect("builder construction failed"),
471                |scope| {
472                    let mut menu_scope = MenuScope { scope };
473                    menu_content(&mut menu_scope);
474                },
475            );
476        },
477    );
478
479    // Parent input handler: block propagation and close on background click.
480    let bounds_for_handler = bounds.clone();
481    let on_dismiss_for_handler = args.on_dismiss.clone();
482    let close_on_escape = args.close_on_escape;
483    let close_on_background = args.close_on_background;
484    let state_for_handler = state.clone();
485    input_handler(Box::new(move |mut input| {
486        let mut cursor_events: Vec<_> = Vec::new();
487        std::mem::swap(&mut cursor_events, input.cursor_events);
488        let cursor_position = input.cursor_position_rel;
489
490        let mut keyboard_events: Vec<_> = Vec::new();
491        std::mem::swap(&mut keyboard_events, input.keyboard_events);
492
493        // Prevent underlying content from receiving input while menu is open.
494        input.block_all();
495
496        let menu_bounds = *bounds_for_handler.read();
497        let should_close_click = close_on_background
498            && should_close_on_click(&cursor_events, cursor_position, menu_bounds);
499
500        let should_close_escape = close_on_escape
501            && keyboard_events.iter().any(|event| {
502                event.state == winit::event::ElementState::Pressed
503                    && matches!(
504                        event.physical_key,
505                        winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape)
506                    )
507            });
508
509        if should_close_click || should_close_escape {
510            apply_close_action(&state_for_handler, &on_dismiss_for_handler);
511        }
512    }));
513
514    // Measurement: place main content, background, and menu based on anchor.
515    let bounds_for_measure = bounds;
516    let args_for_measure = args.clone();
517    measure(Box::new(move |input| {
518        let main_content_id = input
519            .children_ids
520            .first()
521            .copied()
522            .expect("main content should exist");
523        let main_size = input.measure_child(main_content_id, input.parent_constraint)?;
524        input.place_child(main_content_id, PxPosition::new(Px::ZERO, Px::ZERO));
525
526        let background_id = input
527            .children_ids
528            .get(1)
529            .copied()
530            .expect("menu background should exist");
531        let menu_id = input
532            .children_ids
533            .get(2)
534            .copied()
535            .expect("menu surface should exist");
536
537        let background_size = input.measure_child(background_id, input.parent_constraint)?;
538        input.place_child(background_id, PxPosition::new(Px::ZERO, Px::ZERO));
539
540        let menu_size = input.measure_child(menu_id, input.parent_constraint)?;
541        let available = if background_size.width > Px::ZERO && background_size.height > Px::ZERO {
542            background_size
543        } else {
544            extract_available_size(input.parent_constraint)
545        };
546        let menu_position = resolve_menu_position(
547            anchor,
548            args_for_measure.placement,
549            menu_size,
550            available,
551            args_for_measure.offset,
552        );
553        input.place_child(menu_id, menu_position);
554
555        if let Some(mut metadata) = input.metadatas.get_mut(&menu_id) {
556            metadata.clips_children = true;
557        }
558
559        *bounds_for_measure.write() = Some(MenuBounds {
560            origin: menu_position,
561            size: menu_size,
562        });
563
564        Ok(main_size)
565    }));
566}
567
568/// Convenience wrapper for rendering only the menu overlay without extra main content.
569#[tessera]
570pub fn menu(
571    args: impl Into<MenuProviderArgs>,
572    state: MenuState,
573    content: impl FnOnce(&mut MenuScope<'_, '_>) + Send + Sync + 'static,
574) {
575    menu_provider(args, state, || {}, content);
576}
577
578/// Defines the configuration for an individual menu item.
579#[derive(Builder, Clone)]
580#[builder(pattern = "owned")]
581pub struct MenuItemArgs {
582    /// Primary label text for the item.
583    #[builder(setter(into))]
584    pub label: String,
585    /// Optional supporting text displayed under the label.
586    #[builder(default, setter(strip_option, into))]
587    pub supporting_text: Option<String>,
588    /// Optional trailing text (e.g., keyboard shortcut).
589    #[builder(default, setter(strip_option, into))]
590    pub trailing_text: Option<String>,
591    /// Leading icon displayed when the item is not selected.
592    #[builder(default, setter(strip_option))]
593    pub leading_icon: Option<crate::icon::IconArgs>,
594    /// Trailing icon displayed on the right edge.
595    #[builder(default, setter(strip_option))]
596    pub trailing_icon: Option<crate::icon::IconArgs>,
597    /// Whether the item is currently selected (renders a checkmark instead of a leading icon).
598    #[builder(default)]
599    pub selected: bool,
600    /// Whether the item can be interacted with.
601    #[builder(default = "true")]
602    pub enabled: bool,
603    /// Whether the menu should close after the item is activated.
604    #[builder(default = "true")]
605    pub close_on_click: bool,
606    /// Height of the item row.
607    #[builder(default = "MENU_ITEM_HEIGHT")]
608    pub height: Dp,
609    /// Tint applied to the label text.
610    #[builder(default = "crate::material_color::global_material_scheme().on_surface")]
611    pub label_color: Color,
612    /// Tint applied to supporting or trailing text.
613    #[builder(default = "crate::material_color::global_material_scheme().on_surface_variant")]
614    pub supporting_color: Color,
615    /// Tint applied when the item is disabled.
616    #[builder(
617        default = "crate::material_color::global_material_scheme().on_surface.with_alpha(0.38)"
618    )]
619    pub disabled_color: Color,
620    /// Callback invoked when the item is activated.
621    #[builder(default, setter(strip_option))]
622    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
623}
624
625impl Default for MenuItemArgs {
626    fn default() -> Self {
627        MenuItemArgsBuilder::default()
628            .label("")
629            .build()
630            .expect("MenuItemArgsBuilder default build should succeed")
631    }
632}
633
634fn render_leading(args: &MenuItemArgs, enabled: bool) {
635    if args.selected {
636        checkmark(
637            CheckmarkArgsBuilder::default()
638                .color(if enabled {
639                    args.label_color
640                } else {
641                    args.disabled_color
642                })
643                .size(MENU_LEADING_SIZE)
644                .padding([2.0, 2.0])
645                .build()
646                .expect("builder construction failed"),
647        );
648    } else if let Some(icon) = args.leading_icon.clone() {
649        crate::icon::icon(
650            crate::icon::IconArgsBuilder::default()
651                .content(icon.content)
652                .size(icon.size)
653                .width(
654                    icon.width
655                        .unwrap_or_else(|| DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE))),
656                )
657                .height(
658                    icon.height
659                        .unwrap_or_else(|| DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE))),
660                )
661                .tint(if enabled {
662                    args.supporting_color
663                } else {
664                    args.disabled_color
665                })
666                .build()
667                .expect("builder construction failed"),
668        );
669    } else {
670        spacer(
671            SpacerArgsBuilder::default()
672                .width(DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE)))
673                .height(DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE)))
674                .build()
675                .expect("builder construction failed"),
676        );
677    }
678}
679
680fn render_labels(args: &MenuItemArgs, enabled: bool) {
681    let label_color = if enabled {
682        args.label_color
683    } else {
684        args.disabled_color
685    };
686    let supporting_color = if enabled {
687        args.supporting_color
688    } else {
689        args.disabled_color
690    };
691    let label_text = args.label.clone();
692    let supporting_text = args.supporting_text.clone();
693
694    column(
695        ColumnArgsBuilder::default()
696            .width(DimensionValue::WRAP)
697            .cross_axis_alignment(CrossAxisAlignment::Start)
698            .build()
699            .expect("builder construction failed"),
700        |scope| {
701            scope.child(move || {
702                let text_value = label_text.clone();
703                let color = label_color;
704                text(
705                    TextArgsBuilder::default()
706                        .text(text_value)
707                        .size(Dp(16.0))
708                        .color(color)
709                        .build()
710                        .expect("builder construction failed"),
711                );
712            });
713            if let Some(supporting) = supporting_text {
714                scope.child(move || {
715                    let supporting_value = supporting.clone();
716                    let color = supporting_color;
717                    text(
718                        TextArgsBuilder::default()
719                            .text(supporting_value)
720                            .size(Dp(14.0))
721                            .color(color)
722                            .build()
723                            .expect("builder construction failed"),
724                    );
725                });
726            }
727        },
728    );
729}
730
731fn render_trailing(args: &MenuItemArgs, enabled: bool) {
732    if let Some(trailing_icon) = args.trailing_icon.clone() {
733        crate::icon::icon(
734            crate::icon::IconArgsBuilder::default()
735                .content(trailing_icon.content)
736                .size(trailing_icon.size)
737                .width(trailing_icon.width.unwrap_or(DimensionValue::WRAP))
738                .height(trailing_icon.height.unwrap_or(DimensionValue::WRAP))
739                .tint(if enabled {
740                    args.supporting_color
741                } else {
742                    args.disabled_color
743                })
744                .build()
745                .expect("builder construction failed"),
746        );
747    } else if let Some(trailing_text) = args.trailing_text.clone() {
748        text(
749            TextArgsBuilder::default()
750                .text(trailing_text)
751                .size(Dp(14.0))
752                .color(if enabled {
753                    args.supporting_color
754                } else {
755                    args.disabled_color
756                })
757                .build()
758                .expect("builder construction failed"),
759        );
760    }
761}
762
763/// # menu_item
764///
765/// Renders a single Material-styled menu item with hover and ripple feedback.
766///
767/// ## Usage
768///
769/// Use inside [`menu`] to show actions, shortcuts, or toggles.
770///
771/// ## Parameters
772///
773/// - `args` — configures the item label, icons, selection state, and callbacks; see [`MenuItemArgs`].
774/// - `menu_state` — optional [`MenuState`] to auto-close the menu when activated.
775/// - `ripple_state` — a clonable [`RippleState`] used for hover and press feedback.
776///
777/// ## Examples
778///
779/// ```
780/// use std::sync::Arc;
781/// use tessera_ui_basic_components::{
782///     menus::{menu_item, MenuItemArgsBuilder, MenuState},
783///     ripple_state::RippleState,
784/// };
785///
786/// let state = MenuState::new();
787/// let ripple = RippleState::new();
788/// menu_item(
789///     MenuItemArgsBuilder::default()
790///         .label("Copy")
791///         .on_click(Arc::new(|| {}))
792///         .build()
793///         .unwrap(),
794///     Some(state.clone()),
795///     ripple,
796/// );
797/// assert!(!state.is_open());
798/// ```
799#[tessera]
800pub fn menu_item(args: MenuItemArgs, menu_state: Option<MenuState>, ripple_state: RippleState) {
801    let is_enabled = args.enabled && args.on_click.is_some();
802    let on_click = args.on_click.clone();
803    let close_on_click = args.close_on_click;
804
805    let interactive_click = if is_enabled {
806        Some(Arc::new(closure!(clone on_click, clone menu_state, || {
807            if let Some(handler) = &on_click {
808                handler();
809            }
810            if close_on_click && let Some(state) = &menu_state {
811                state.close();
812            }
813        })) as Arc<dyn Fn() + Send + Sync>)
814    } else {
815        None
816    };
817
818    let mut surface_builder = SurfaceArgsBuilder::default()
819        .style(SurfaceStyle::Filled {
820            color: Color::TRANSPARENT,
821        })
822        .hover_style(is_enabled.then(|| SurfaceStyle::Filled {
823            color: default_hover_color(),
824        }))
825        .padding(Dp(0.0))
826        .width(DimensionValue::FILLED)
827        .height(DimensionValue::Wrap {
828            min: Some(Px::from(args.height)),
829            max: None,
830        })
831        .accessibility_role(Role::MenuItem)
832        .accessibility_label(args.label.clone())
833        .block_input(true)
834        .ripple_color(
835            crate::material_color::global_material_scheme()
836                .on_surface
837                .with_alpha(0.12),
838        );
839
840    if let Some(click) = interactive_click {
841        surface_builder = surface_builder.on_click(click);
842    }
843
844    if let Some(description) = args.supporting_text.clone() {
845        surface_builder = surface_builder.accessibility_description(description);
846    }
847
848    surface(
849        surface_builder
850            .build()
851            .expect("builder construction failed"),
852        Some(ripple_state),
853        || {
854            row(
855                RowArgsBuilder::default()
856                    .width(DimensionValue::FILLED)
857                    .height(DimensionValue::Wrap {
858                        min: Some(Px::from(args.height)),
859                        max: None,
860                    })
861                    .cross_axis_alignment(CrossAxisAlignment::Center)
862                    .build()
863                    .expect("builder construction failed"),
864                |row_scope| {
865                    // Leading padding
866                    row_scope.child(|| {
867                        spacer(
868                            SpacerArgsBuilder::default()
869                                .width(DimensionValue::Fixed(Px::from(MENU_HORIZONTAL_PADDING)))
870                                .build()
871                                .expect("builder construction failed"),
872                        );
873                    });
874
875                    // Leading indicator / icon.
876                    let leading_args = args.clone();
877                    row_scope.child(move || {
878                        render_leading(&leading_args, is_enabled);
879                    });
880
881                    // Gap after leading.
882                    row_scope.child(|| {
883                        spacer(
884                            SpacerArgsBuilder::default()
885                                .width(DimensionValue::Fixed(Px::from(MENU_HORIZONTAL_PADDING)))
886                                .build()
887                                .expect("builder construction failed"),
888                        );
889                    });
890
891                    // Labels column.
892                    let label_args = args.clone();
893                    row_scope.child(move || {
894                        render_labels(&label_args, is_enabled);
895                    });
896
897                    // Flexible spacer.
898                    row_scope.child_weighted(
899                        || {
900                            spacer(
901                                SpacerArgsBuilder::default()
902                                    .width(DimensionValue::FILLED)
903                                    .build()
904                                    .expect("builder construction failed"),
905                            );
906                        },
907                        1.0,
908                    );
909
910                    // Trailing text/icon if any.
911                    if args.trailing_icon.is_some() || args.trailing_text.is_some() {
912                        let trailing_args = args.clone();
913                        row_scope.child(move || {
914                            render_trailing(&trailing_args, is_enabled);
915                        });
916
917                        row_scope.child(|| {
918                            spacer(
919                                SpacerArgsBuilder::default()
920                                    .width(DimensionValue::Fixed(Px::from(MENU_TRAILING_SPACING)))
921                                    .build()
922                                    .expect("builder construction failed"),
923                            );
924                        });
925                    } else {
926                        row_scope.child(|| {
927                            spacer(
928                                SpacerArgsBuilder::default()
929                                    .width(DimensionValue::Fixed(Px::from(MENU_HORIZONTAL_PADDING)))
930                                    .build()
931                                    .expect("builder construction failed"),
932                            );
933                        });
934                    }
935                },
936            );
937        },
938    );
939}