Skip to main content

tessera_components/
floating_action_button_menu.rs

1//! Floating action menus with staggered action expansion.
2//!
3//! ## Usage
4//!
5//! Offer related creation actions above a persistent floating action button.
6
7use tessera_ui::{
8    AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusProperties, FocusRequester,
9    FocusScopeNode, FrameNanosControl, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
10    RenderSlot, State,
11    accesskit::{HasPopup, Role},
12    current_frame_nanos,
13    layout::{MeasureScope, layout},
14    modifier::FocusModifierExt as _,
15    receive_frame_nanos, remember, tessera, use_context, winit,
16};
17
18use crate::{
19    alignment::{Alignment, CrossAxisAlignment},
20    animation::Animatable,
21    boxed::boxed,
22    column::column,
23    icon::icon,
24    material_icons::filled,
25    modifier::{ModifierExt as _, SemanticsArgs, with_keyboard_input, with_pointer_input},
26    painter::Painter,
27    pos_misc::is_position_in_rect,
28    res::I18N,
29    scrollable::ScrollableController,
30    shape_def::{RoundedCorner, Shape},
31    surface::surface,
32    text::text,
33    theme::MaterialTheme,
34};
35
36/// Shared visibility state for a floating action menu.
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
38pub struct FloatingActionButtonMenuState {
39    expanded: bool,
40}
41
42impl FloatingActionButtonMenuState {
43    /// Creates a closed menu.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Returns whether the actions are expanded.
49    pub fn is_expanded(&self) -> bool {
50        self.expanded
51    }
52
53    /// Opens the action list.
54    pub fn open(&mut self) {
55        self.expanded = true;
56    }
57
58    /// Closes the action list.
59    pub fn close(&mut self) {
60        self.expanded = false;
61    }
62
63    /// Toggles the action list.
64    pub fn toggle(&mut self) {
65        self.expanded = !self.expanded;
66    }
67}
68
69/// An action in a floating action menu.
70#[derive(Clone, Default, PartialEq)]
71pub struct FloatingActionButtonMenuItem {
72    /// Visible label and accessible action name.
73    pub label: String,
74    /// Optional leading icon, rendered at 24dp.
75    pub icon: Option<Painter>,
76    /// Action invoked after closing the menu.
77    pub on_click: Option<Callback>,
78}
79
80impl FloatingActionButtonMenuItem {
81    /// Creates an action with a label, optional icon, and callback.
82    pub fn new(label: impl Into<String>, icon: Option<Painter>, on_click: Callback) -> Self {
83        Self {
84            label: label.into(),
85            icon,
86            on_click: Some(on_click),
87        }
88    }
89}
90
91/// Theme color set used by the action pills and toggle button.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum FloatingActionButtonMenuColor {
94    /// Use primary container actions and a primary close button.
95    #[default]
96    Primary,
97    /// Use secondary container actions and a secondary close button.
98    Secondary,
99    /// Use tertiary container actions and a tertiary close button.
100    Tertiary,
101}
102
103/// Horizontal edge where a floating action menu is anchored.
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum FloatingActionButtonMenuAlignment {
106    /// Align the menu to the left edge.
107    Start,
108    /// Align the menu to the right edge.
109    #[default]
110    End,
111}
112
113struct ItemMotion {
114    width: Animatable,
115    opacity: Animatable,
116}
117
118impl ItemMotion {
119    fn new(visible: bool) -> Self {
120        let value = f32::from(visible);
121        Self {
122            width: Animatable::spring(value, 800.0_f32.sqrt(), 0.6),
123            opacity: Animatable::spring(value, 3800.0_f32.sqrt(), 1.0),
124        }
125    }
126}
127
128struct MenuMotion {
129    count: Animatable,
130    toggle: Animatable,
131    items: Vec<ItemMotion>,
132    frame_nanos: u64,
133}
134
135impl MenuMotion {
136    fn new(expanded: bool, count: usize, frame_nanos: u64) -> Self {
137        Self {
138            count: Animatable::spring(
139                if expanded { count as f32 } else { 0.0 },
140                800.0_f32.sqrt(),
141                1.0,
142            ),
143            toggle: Animatable::spring(f32::from(expanded), 3800.0_f32.sqrt(), 1.0),
144            items: (0..count).map(|_| ItemMotion::new(expanded)).collect(),
145            frame_nanos,
146        }
147    }
148
149    fn set_target(&mut self, expanded: bool, count: usize) {
150        self.items.resize_with(count, || ItemMotion::new(false));
151        self.count
152            .set_target(if expanded { count as f32 } else { 0.0 });
153        self.toggle.set_target(f32::from(expanded));
154    }
155
156    fn advance(&mut self, frame_nanos: u64) {
157        let dt = frame_nanos.saturating_sub(self.frame_nanos) as f32 / 1_000_000_000.0;
158        self.frame_nanos = frame_nanos;
159        self.count.tick(dt);
160        self.toggle.tick(dt);
161        let count = self
162            .count
163            .value()
164            .round()
165            .clamp(0.0, self.items.len() as f32) as usize;
166        let first = self.items.len() - count;
167        for (index, item) in self.items.iter_mut().enumerate() {
168            let target = f32::from(index >= first);
169            item.width.set_target(target);
170            item.opacity.set_target(target);
171            item.width.tick(dt);
172            item.opacity.tick(dt);
173        }
174    }
175
176    fn is_idle(&self) -> bool {
177        self.count.is_idle()
178            && self.toggle.is_idle()
179            && self
180                .items
181                .iter()
182                .all(|item| item.width.is_idle() && item.opacity.is_idle())
183    }
184
185    fn is_visible(&self) -> bool {
186        self.items.iter().any(|item| item.opacity.value() > 0.001)
187    }
188}
189
190#[derive(Clone, Copy, PartialEq)]
191struct PanelBounds {
192    position: PxPosition,
193    size: ComputedData,
194}
195
196impl Default for PanelBounds {
197    fn default() -> Self {
198        Self {
199            position: PxPosition::ZERO,
200            size: ComputedData::ZERO,
201        }
202    }
203}
204
205#[derive(Clone, PartialEq)]
206struct MenuLayout {
207    alignment: FloatingActionButtonMenuAlignment,
208    bounds: State<PanelBounds>,
209}
210
211impl tessera_ui::LayoutModifierNode for MenuLayout {
212    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
213        let children = input.children();
214        let main = children[0].measure(input, &input.parent_constraint().without_min())?;
215        let parent = input.parent_constraint();
216        let size = ComputedData {
217            width: parent.width().resolve_max().unwrap_or(main.width),
218            height: parent.height().resolve_max().unwrap_or(main.height),
219        };
220        children[1].measure(input, &Constraint::exact(size.width, size.height))?;
221        let margin = Dp(16.0).to_px();
222        let panel = children[2].measure(
223            input,
224            &Constraint::new(
225                AxisConstraint::new(Px::ZERO, Some((size.width - margin * 2).max(Px::ZERO))),
226                AxisConstraint::new(Px::ZERO, Some((size.height - margin * 2).max(Px::ZERO))),
227            ),
228        )?;
229        let x = match self.alignment {
230            FloatingActionButtonMenuAlignment::Start => {
231                margin.min((size.width - panel.width).max(Px::ZERO))
232            }
233            FloatingActionButtonMenuAlignment::End => {
234                (size.width - margin - panel.width).max(Px::ZERO)
235            }
236        };
237        let position = PxPosition::new(x, (size.height - margin - panel.height).max(Px::ZERO));
238        self.bounds.set_if_changed(PanelBounds {
239            position,
240            size: panel.size(),
241        });
242        let mut result = LayoutResult::new(size);
243        result.place_child(children[0], PxPosition::ZERO);
244        result.place_child(children[1], PxPosition::ZERO);
245        result.place_child(children[2], position);
246        Ok(result)
247    }
248}
249
250#[derive(Clone, Copy, PartialEq)]
251struct PanelLayout {
252    alignment: FloatingActionButtonMenuAlignment,
253}
254
255impl tessera_ui::LayoutModifierNode for PanelLayout {
256    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
257        let children = input.children();
258        let button = children[1].measure(input, &input.parent_constraint().without_min())?;
259        let max_height = input
260            .parent_constraint()
261            .height()
262            .resolve_max()
263            .unwrap_or(Px::MAX);
264        let list = children[0].measure(
265            input,
266            &Constraint::new(
267                input.parent_constraint().width().without_min(),
268                AxisConstraint::new(
269                    Px::ZERO,
270                    Some((max_height - button.height - Dp(8.0).to_px()).max(Px::ZERO)),
271                ),
272            ),
273        )?;
274        let gap = if list.height > Px::ZERO {
275            Dp(8.0).to_px()
276        } else {
277            Px::ZERO
278        };
279        let width = button.width.max(list.width);
280        let height = list.height + gap + button.height;
281        let mut result = LayoutResult::new(ComputedData { width, height });
282        let x = |child_width| match self.alignment {
283            FloatingActionButtonMenuAlignment::Start => Px::ZERO,
284            FloatingActionButtonMenuAlignment::End => width - child_width,
285        };
286        result.place_child(children[0], PxPosition::new(x(list.width), Px::ZERO));
287        result.place_child(
288            children[1],
289            PxPosition::new(x(button.width), list.height + gap),
290        );
291        Ok(result)
292    }
293}
294
295#[derive(Clone, Copy, PartialEq)]
296struct RevealLayout {
297    progress: f32,
298    visible: bool,
299    gap: bool,
300    alignment: FloatingActionButtonMenuAlignment,
301}
302
303#[derive(Clone, Copy, PartialEq)]
304struct ActionContentLayout {
305    has_icon: bool,
306}
307
308impl tessera_ui::LayoutModifierNode for ActionContentLayout {
309    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
310        let children = input.children();
311        let padding = Dp(24.0).to_px();
312        let leading = if self.has_icon {
313            Dp(32.0).to_px()
314        } else {
315            Px::ZERO
316        };
317        let label = children[usize::from(self.has_icon)];
318        let label_size = label.measure(
319            input,
320            &Constraint::new(
321                AxisConstraint::new(
322                    Px::ZERO,
323                    input
324                        .parent_constraint()
325                        .width()
326                        .resolve_max()
327                        .map(|max| (max - padding * 2 - leading).max(Px::ZERO)),
328                ),
329                AxisConstraint::new(Px::ZERO, Some(Dp(24.0).to_px())),
330            ),
331        )?;
332        let width = input
333            .parent_constraint()
334            .width()
335            .clamp((padding * 2 + leading + label_size.width).max(Dp(56.0).to_px()));
336        let height = input.parent_constraint().height().clamp(Dp(56.0).to_px());
337        let mut result = LayoutResult::new(ComputedData { width, height });
338        if self.has_icon {
339            let icon = children[0];
340            let side = Dp(24.0).to_px();
341            icon.measure(input, &Constraint::exact(side, side))?;
342            result.place_child(icon, PxPosition::new(padding, (height - side) / 2));
343        }
344        result.place_child(
345            label,
346            PxPosition::new(padding + leading, (height - label_size.height) / 2),
347        );
348        Ok(result)
349    }
350}
351
352impl tessera_ui::LayoutModifierNode for RevealLayout {
353    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
354        let child = input.children()[0];
355        let size = child.measure(input, &input.parent_constraint().without_min())?;
356        let width = input.parent_constraint().width().clamp(Px::new(
357            (size.width.to_f32() * self.progress.max(0.0)).round() as i32,
358        ));
359        let gap = if self.gap { Dp(4.0).to_px() } else { Px::ZERO };
360        let height = if self.visible {
361            size.height + gap
362        } else {
363            Px::ZERO
364        };
365        let mut result = LayoutResult::new(ComputedData { width, height });
366        let x = if self.alignment == FloatingActionButtonMenuAlignment::End {
367            width - size.width
368        } else {
369            Px::ZERO
370        };
371        result.place_child(child, PxPosition::new(x, gap));
372        Ok(result)
373    }
374}
375
376#[derive(Clone, PartialEq)]
377struct MenuData {
378    items: Vec<FloatingActionButtonMenuItem>,
379    requesters: Vec<FocusRequester>,
380    trigger: FocusRequester,
381    expanded: bool,
382    enabled: bool,
383    alignment: FloatingActionButtonMenuAlignment,
384    colors: [Color; 4],
385    open_label: String,
386    close_label: String,
387    scrim_color: Color,
388}
389
390fn dismiss(state: State<FloatingActionButtonMenuState>, trigger: FocusRequester) {
391    state.with_mut(FloatingActionButtonMenuState::close);
392    trigger.request_focus();
393}
394
395fn focus_properties(data: &MenuData, index: usize) -> FocusProperties {
396    let enabled: Vec<_> = data
397        .items
398        .iter()
399        .enumerate()
400        .filter(|(_, item)| item.on_click.is_some())
401        .map(|(i, _)| i)
402        .collect();
403    let mut order = enabled;
404    order.push(data.items.len());
405    let position = order
406        .iter()
407        .position(|i| *i == index)
408        .unwrap_or(order.len() - 1);
409    let requester = |i: usize| {
410        if i == data.items.len() {
411            data.trigger
412        } else {
413            data.requesters[i]
414        }
415    };
416    if !data.expanded {
417        return FocusProperties::default().can_focus(index == data.items.len() && data.enabled);
418    }
419    let next = requester(order[(position + 1) % order.len()]);
420    let previous = requester(order[(position + order.len() - 1) % order.len()]);
421    FocusProperties::default()
422        .next(next)
423        .down(next)
424        .previous(previous)
425        .up(previous)
426}
427
428/// # floating_action_button_menu
429///
430/// Overlay related primary actions above a floating action button.
431///
432/// ## Usage
433///
434/// Offer two to six related creation actions while retaining the page beneath
435/// them.
436///
437/// ## Parameters
438///
439/// - `state` — optional shared visibility state; otherwise stored internally.
440/// - `items` — action labels, optional icons, and callbacks; missing callbacks
441///   disable actions.
442/// - `main_content` — page content beneath the menu, preserved across
443///   visibility changes.
444/// - `modifier` — size and placement of the entire menu host.
445/// - `alignment` — bottom start or bottom end, inset by 16dp.
446/// - `color` — primary, secondary, or tertiary theme colors.
447/// - `scrim_color` — optional overlay color; transparent by default.
448/// - `enabled` — whether the toggle and actions accept input.
449/// - `open_label`, `close_label` — localized accessible names for the toggle.
450///
451/// ## Examples
452///
453/// ```
454/// use tessera_components::floating_action_button_menu::{
455///     FloatingActionButtonMenuState, floating_action_button_menu,
456/// };
457/// use tessera_ui::{remember, tessera};
458///
459/// let mut visibility = FloatingActionButtonMenuState::new();
460/// assert!(!visibility.is_expanded());
461/// visibility.open();
462/// assert!(visibility.is_expanded());
463/// visibility.close();
464/// assert!(!visibility.is_expanded());
465///
466/// #[tessera]
467/// fn creation_actions() {
468///     let state = remember(FloatingActionButtonMenuState::new);
469///     floating_action_button_menu().state(state);
470/// }
471/// ```
472#[tessera]
473pub fn floating_action_button_menu(
474    state: Option<State<FloatingActionButtonMenuState>>,
475    items: Option<Vec<FloatingActionButtonMenuItem>>,
476    main_content: Option<RenderSlot>,
477    modifier: Option<Modifier>,
478    alignment: Option<FloatingActionButtonMenuAlignment>,
479    color: Option<FloatingActionButtonMenuColor>,
480    scrim_color: Option<Color>,
481    enabled: Option<bool>,
482    #[prop(into)] open_label: Option<String>,
483    #[prop(into)] close_label: Option<String>,
484) {
485    let state = if let Some(state) = state {
486        state
487    } else {
488        remember(FloatingActionButtonMenuState::new)
489    };
490    let enabled = enabled.unwrap_or(true);
491    if !enabled && state.with(FloatingActionButtonMenuState::is_expanded) {
492        state.with_mut(FloatingActionButtonMenuState::close);
493    }
494    let expanded = state.with(FloatingActionButtonMenuState::is_expanded);
495    let items = items.unwrap_or_default();
496    let theme = use_context::<MaterialTheme>()
497        .expect("MaterialTheme must be provided")
498        .get();
499    let scheme = theme.color_scheme;
500    let colors = match color.unwrap_or_default() {
501        FloatingActionButtonMenuColor::Primary => [
502            scheme.primary_container,
503            scheme.on_primary_container,
504            scheme.primary,
505            scheme.on_primary,
506        ],
507        FloatingActionButtonMenuColor::Secondary => [
508            scheme.secondary_container,
509            scheme.on_secondary_container,
510            scheme.secondary,
511            scheme.on_secondary,
512        ],
513        FloatingActionButtonMenuColor::Tertiary => [
514            scheme.tertiary_container,
515            scheme.on_tertiary_container,
516            scheme.tertiary,
517            scheme.on_tertiary,
518        ],
519    };
520    let alignment = alignment.unwrap_or_default();
521    let trigger = remember(FocusRequester::new).get();
522    let requesters = remember(Vec::<FocusRequester>::new);
523    if requesters.with(|v| v.len()) != items.len() {
524        requesters.with_mut(|v| v.resize_with(items.len(), FocusRequester::new));
525    }
526    let current = MenuData {
527        items,
528        requesters: requesters.get(),
529        trigger,
530        expanded,
531        enabled,
532        alignment,
533        colors,
534        open_label: open_label.unwrap_or_else(|| I18N.text("md3-open-actions").into()),
535        close_label: close_label.unwrap_or_else(|| I18N.text("md3-close-actions").into()),
536        scrim_color: scrim_color.unwrap_or(Color::TRANSPARENT),
537    };
538    let data = remember(|| current.clone());
539    if data.with(|data| data != &current) {
540        data.set(current);
541    }
542    let now = current_frame_nanos();
543    let count = data.with(|data| data.items.len());
544    let motion = remember(|| MenuMotion::new(expanded, count, now));
545    if motion
546        .with(|motion| motion.toggle.target() != f32::from(expanded) || motion.items.len() != count)
547    {
548        motion.with_mut(|motion| {
549            motion.advance(now);
550            motion.set_target(expanded, count);
551        });
552    }
553    if !motion.with(MenuMotion::is_idle) {
554        receive_frame_nanos(move |now| {
555            motion.with_mut(|motion| motion.advance(now));
556            if motion.with(MenuMotion::is_idle) {
557                FrameNanosControl::Stop
558            } else {
559                FrameNanosControl::Continue
560            }
561        });
562    }
563    let bounds = remember(PanelBounds::default);
564    let pending_focus = remember(|| false);
565    let previous = remember(|| false);
566    if previous.get() != expanded {
567        previous.set(expanded);
568        pending_focus.set(true);
569    }
570    layout()
571        .modifier(modifier.unwrap_or_default())
572        .policy(MenuLayout {
573            alignment,
574            bounds: bounds.clone(),
575        })
576        .child(move || {
577            boxed()
578                .modifier(Modifier::new().semantics(SemanticsArgs {
579                    hidden: data.with(|data| data.expanded),
580                    merge_descendants: true,
581                    ..Default::default()
582                }))
583                .children_optional(main_content.clone());
584            fab_menu_scrim(data, state, motion).bounds(bounds.clone());
585            fab_menu_panel(data, state, motion, pending_focus);
586        });
587}
588
589#[tessera]
590fn fab_menu_scrim(
591    data: State<MenuData>,
592    state: State<FloatingActionButtonMenuState>,
593    motion: State<MenuMotion>,
594    bounds: Option<State<PanelBounds>>,
595) {
596    let bounds = bounds.expect("menu bounds must be provided");
597    let active = data.with(|data| data.expanded) || motion.with(MenuMotion::is_visible);
598    let modifier = if active {
599        with_pointer_input(Modifier::new().fill_max_size(), move |mut input| {
600            let bounds = bounds.get();
601            let outside = input.cursor_position_rel.is_some_and(|position| {
602                !is_position_in_rect(
603                    position,
604                    bounds.position,
605                    bounds.size.width,
606                    bounds.size.height,
607                )
608            });
609            let close = outside && input.has_unconsumed_release();
610            input.block_all();
611            if close {
612                dismiss(state, data.with(|data| data.trigger));
613            }
614        })
615    } else {
616        Modifier::new().fill_max_size()
617    };
618    boxed()
619        .modifier(modifier.background(if active {
620            data.with(|data| data.scrim_color)
621        } else {
622            Color::TRANSPARENT
623        }))
624        .children(|| {});
625}
626
627#[tessera]
628fn fab_menu_panel(
629    data: State<MenuData>,
630    state: State<FloatingActionButtonMenuState>,
631    motion: State<MenuMotion>,
632    pending_focus: State<bool>,
633) {
634    let snapshot = data.get();
635    let scope = remember(FocusScopeNode::new).get();
636    let scroll = remember(ScrollableController::new);
637    let modifier = with_keyboard_input(
638        Modifier::new()
639            .focus_scope_with(scope)
640            .semantics(SemanticsArgs {
641                test_tag: cfg!(test).then(|| "fab-menu-panel".into()),
642                merge_descendants: true,
643                ..Default::default()
644            }),
645        move |mut input| {
646            if !data.with(|data| data.expanded)
647                || input.key_modifiers.control_key()
648                || input.key_modifiers.alt_key()
649                || input.key_modifiers.super_key()
650            {
651                return;
652            }
653            if input.keyboard_events.iter().any(|event| {
654                event.state == winit::event::ElementState::Pressed
655                    && event.logical_key
656                        == winit::keyboard::Key::Named(winit::keyboard::NamedKey::Escape)
657            }) {
658                dismiss(state, data.with(|data| data.trigger));
659                input.block_keyboard();
660            }
661        },
662    );
663    layout()
664        .modifier(modifier)
665        .policy(PanelLayout {
666            alignment: snapshot.alignment,
667        })
668        .child(move || {
669            column()
670                .modifier(
671                    Modifier::new()
672                        .clip_to_bounds()
673                        .vertical_scroll(scroll)
674                        .semantics(SemanticsArgs {
675                            role: Some(Role::Menu),
676                            hidden: !data.with(|data| data.expanded),
677                            merge_descendants: true,
678                            ..Default::default()
679                        }),
680                )
681                .cross_axis_alignment(
682                    if data.with(|data| data.alignment) == FloatingActionButtonMenuAlignment::End {
683                        CrossAxisAlignment::End
684                    } else {
685                        CrossAxisAlignment::Start
686                    },
687                )
688                .children(move || {
689                    let first_visible = motion.with(|motion| {
690                        motion
691                            .items
692                            .iter()
693                            .position(|item| item.opacity.value() > 0.001)
694                    });
695                    for index in 0..data.with(|data| data.items.len()) {
696                        fab_menu_action(
697                            index,
698                            data,
699                            state,
700                            motion,
701                            first_visible.is_some_and(|first| index > first),
702                        );
703                    }
704                });
705            let snapshot = data.get();
706            let progress = motion.with(|motion| motion.toggle.value().clamp(0.0, 1.0));
707            let corner =
708                RoundedCorner::manual(Dp(16.0 + 12.0 * f64::from(progress)), 3.0 - progress);
709            let semantics = SemanticsArgs {
710                expanded: Some(snapshot.expanded),
711                has_popup: Some(HasPopup::Menu),
712                ..Default::default()
713            };
714            surface()
715                .modifier(
716                    Modifier::new()
717                        .size(Dp(56.0), Dp(56.0))
718                        .semantics(semantics)
719                        .on_globally_positioned(move |_| {
720                            if pending_focus.get() {
721                                pending_focus.set(false);
722                                data.with(|data| data.trigger).request_focus();
723                            }
724                        }),
725                )
726                .style(
727                    snapshot.colors[0]
728                        .lerp(&snapshot.colors[2], progress)
729                        .into(),
730                )
731                .content_color(snapshot.colors[1].lerp(&snapshot.colors[3], progress))
732                .shape(Shape::RoundedRectangle {
733                    top_left: corner,
734                    top_right: corner,
735                    bottom_right: corner,
736                    bottom_left: corner,
737                })
738                .elevation(Dp(6.0))
739                .tonal_elevation(Dp(0.0))
740                .content_alignment(Alignment::Center)
741                .enabled(snapshot.enabled)
742                .accessibility_label(if snapshot.expanded {
743                    snapshot.close_label.clone()
744                } else {
745                    snapshot.open_label.clone()
746                })
747                .accessibility_focusable(snapshot.enabled)
748                .focus_requester(snapshot.trigger)
749                .focus_properties(focus_properties(&snapshot, snapshot.items.len()))
750                .on_click(move || {
751                    state.with_mut(FloatingActionButtonMenuState::toggle);
752                })
753                .child(move || {
754                    icon()
755                        .painter(if data.with(|data| data.expanded) {
756                            filled::CLOSE_SVG
757                        } else {
758                            filled::ADD_SVG
759                        })
760                        .size(Dp(24.0
761                            - f64::from(
762                                motion.with(|motion| motion.toggle.value().clamp(0.0, 1.0)),
763                            ) * 4.0));
764                });
765        });
766}
767
768#[tessera]
769fn fab_menu_action(
770    index: usize,
771    data: State<MenuData>,
772    state: State<FloatingActionButtonMenuState>,
773    motion: State<MenuMotion>,
774    gap: bool,
775) {
776    let snapshot = data.get();
777    let (progress, opacity) = motion.with(|motion| {
778        (
779            motion.items[index].width.value(),
780            motion.items[index].opacity.value().clamp(0.0, 1.0),
781        )
782    });
783    let visible = opacity > 0.001;
784    layout()
785        .modifier(
786            Modifier::new()
787                .clip_to_bounds()
788                .alpha(opacity)
789                .semantics(SemanticsArgs {
790                    hidden: !visible || !snapshot.expanded,
791                    test_tag: cfg!(test).then(|| format!("fab-menu-item-{index}")),
792                    merge_descendants: true,
793                    ..Default::default()
794                }),
795        )
796        .policy(RevealLayout {
797            progress,
798            visible,
799            gap,
800            alignment: snapshot.alignment,
801        })
802        .child(move || {
803            let snapshot = data.get();
804            let item = snapshot.items[index].clone();
805            let visible = motion.with(|motion| motion.items[index].opacity.value() > 0.001);
806            let enabled =
807                snapshot.enabled && snapshot.expanded && visible && item.on_click.is_some();
808            surface()
809                .modifier(
810                    Modifier::new()
811                        .default_min_size(Dp(56.0), Dp(56.0))
812                        .height(Dp(56.0)),
813                )
814                .style(snapshot.colors[0].into())
815                .content_color(snapshot.colors[1])
816                .shape(Shape::CAPSULE)
817                .content_alignment(Alignment::CenterStart)
818                .tonal_elevation(Dp(0.0))
819                .enabled(enabled)
820                .accessibility_role(Role::MenuItem)
821                .accessibility_label(item.label.clone())
822                .accessibility_focusable(enabled)
823                .focus_requester(snapshot.requesters[index])
824                .focus_properties(focus_properties(&snapshot, index).can_focus(enabled))
825                .on_click(move || {
826                    let snapshot = data.get();
827                    dismiss(state, snapshot.trigger);
828                    if let Some(callback) = &snapshot.items[index].on_click {
829                        callback.call();
830                    }
831                })
832                .child(move || {
833                    layout()
834                        .policy(ActionContentLayout {
835                            has_icon: data.with(|data| data.items[index].icon.is_some()),
836                        })
837                        .child(move || {
838                            let item = data.with(|data| data.items[index].clone());
839                            let style = use_context::<MaterialTheme>()
840                                .unwrap()
841                                .get()
842                                .typography
843                                .title_medium;
844                            if let Some(painter) = item.icon.clone() {
845                                icon().painter(painter).size(Dp(24.0));
846                            }
847                            text(item.label.clone())
848                                .style(style)
849                                .modifier(Modifier::new().clip_to_bounds());
850                        });
851                });
852        });
853}
854
855#[cfg(test)]
856mod tests {
857    use tessera_ui::{
858        CursorEventContent, GestureState, PointerChange, PressKeyEventType,
859        testing::{
860            layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
861            start_layout_test_session,
862        },
863    };
864
865    use crate::theme::material_theme;
866
867    use super::*;
868
869    #[derive(Clone, Default)]
870    struct FixtureHandles {
871        state: Option<State<FloatingActionButtonMenuState>>,
872        background: Option<State<usize>>,
873        action: Option<State<usize>>,
874    }
875
876    fn click(
877        session: &mut tessera_ui::testing::LayoutTestSession<impl Fn()>,
878        frame: &mut u64,
879        x: f32,
880        y: f32,
881    ) -> tessera_ui::testing::LayoutSnapshot {
882        for event in [
883            CursorEventContent::Pressed(PressKeyEventType::Left),
884            CursorEventContent::Released(PressKeyEventType::Left),
885        ] {
886            *frame += 1;
887            pump_layout_test_session_with_input(
888                session,
889                *frame * 16_666_667,
890                vec![PointerChange::new(
891                    tessera_ui::time::Instant::now(),
892                    1,
893                    event,
894                    GestureState::TapCandidate,
895                )],
896                Some(PxPosition::from_f32_xy(x, y)),
897            );
898        }
899        *frame += 1;
900        pump_layout_test_session(session, *frame * 16_666_667)
901    }
902
903    #[tessera]
904    fn fixture(
905        expanded: bool,
906        alignment: FloatingActionButtonMenuAlignment,
907        count: usize,
908        probe: Option<tessera_ui::testing::TestProbe<FixtureHandles>>,
909    ) {
910        let state = remember(|| FloatingActionButtonMenuState { expanded });
911        let action = remember(|| 0usize);
912        if let Some(probe) = &probe {
913            probe.with_mut(|handles| handles.state = Some(state));
914        }
915        if let Some(probe) = &probe {
916            probe.with_mut(|handles| handles.action = Some(action));
917        }
918        material_theme().child(move || {
919            let probe = probe.clone();
920            let items = (0..count)
921                .map(|index| FloatingActionButtonMenuItem {
922                    label: format!("Action {index}"),
923                    icon: Some(filled::EDIT_SVG.into()),
924                    on_click: if index == 1 {
925                        None
926                    } else {
927                        Some(Callback::new(move || action.set(index + 1)))
928                    },
929                })
930                .collect();
931            floating_action_button_menu()
932                .state(state)
933                .alignment(alignment)
934                .items(items)
935                .main_content(move || {
936                    let background = remember(|| 7usize);
937                    if let Some(probe) = &probe {
938                        probe.with_mut(|handles| handles.background = Some(background));
939                    }
940                    surface()
941                        .modifier(Modifier::new().size(Dp(120.0), Dp(48.0)))
942                        .on_click(move || background.with_mut(|value| *value += 1))
943                        .child(|| {});
944                });
945        });
946    }
947
948    #[test]
949    fn action_list_matches_spacing_and_stays_inside_short_viewports() {
950        for (width, height) in [(400, 400), (240, 200)] {
951            let snapshot = layout_test(|| {
952                fixture(true, FloatingActionButtonMenuAlignment::End, 3);
953            })
954            .viewport_px(width, height)
955            .run();
956            let panel = snapshot.node("fab-menu-panel").bounds();
957            assert_eq!(panel.x + panel.width, Px::new(width as i32 - 16));
958            assert_eq!(panel.y + panel.height, Px::new(height as i32 - 16));
959            assert!(panel.y >= Px::new(16));
960            assert!(snapshot.node("fab-menu-item-0").bounds().width <= Px::new(width as i32 - 32));
961            snapshot
962                .node("fab-menu-item-0")
963                .size(snapshot.node("fab-menu-item-0").bounds().width.raw(), 56);
964            assert_eq!(
965                snapshot.node("fab-menu-item-1").bounds().height,
966                Px::new(60)
967            );
968            if height == 400 {
969                assert_eq!(panel.height, Px::new(240));
970                assert_eq!(snapshot.node("fab-menu-item-2").bounds().y, Px::new(260));
971            } else {
972                assert_eq!(panel.height, Px::new(168));
973            }
974        }
975        let snapshot = layout_test(|| {
976            fixture(false, FloatingActionButtonMenuAlignment::Start, 3);
977        })
978        .viewport_px(400, 400)
979        .run();
980        snapshot.node("fab-menu-panel").size(56, 56);
981        assert_eq!(snapshot.node("fab-menu-panel").bounds().x, Px::new(16));
982    }
983
984    #[test]
985    fn stagger_reveals_bottom_first_hides_top_first_and_reverses_to_rest() {
986        let mut motion = MenuMotion::new(false, 3, 0);
987        motion.set_target(true, 3);
988        motion.advance(33_333_334);
989        assert!(motion.items[2].opacity.value() > 0.0);
990        assert_eq!(motion.items[0].opacity.value(), 0.0);
991        for frame in 3..120 {
992            motion.advance(frame * 16_666_667);
993        }
994        assert!(motion.is_idle());
995        motion.set_target(false, 3);
996        motion.advance(2_033_333_334);
997        assert!(motion.items[0].opacity.value() < motion.items[2].opacity.value());
998        motion.set_target(true, 3);
999        for frame in 123..260 {
1000            motion.advance(frame * 16_666_667);
1001        }
1002        assert!(motion.is_idle());
1003        assert!(motion.items.iter().all(|item| item.opacity.value() == 1.0));
1004        motion.set_target(false, 3);
1005        for frame in 260..400 {
1006            motion.advance(frame * 16_666_667);
1007        }
1008        assert!(motion.is_idle());
1009        assert!(!motion.is_visible());
1010    }
1011
1012    #[test]
1013    fn overlay_blocks_background_preserves_state_and_actions_dismiss() {
1014        let probe = tessera_ui::testing::TestProbe::<FixtureHandles>::default();
1015
1016        let mut session = start_layout_test_session(
1017            layout_test(|| {
1018                fixture(false, FloatingActionButtonMenuAlignment::End, 3).probe(probe.clone());
1019            })
1020            .viewport_px(400, 400),
1021        );
1022        pump_layout_test_session(&mut session, 0);
1023        let mut frame = 0u64;
1024        click(&mut session, &mut frame, 60.0, 24.0);
1025        assert_eq!(probe.get().background.unwrap().get(), 8);
1026        click(&mut session, &mut frame, 356.0, 356.0);
1027        assert!(
1028            probe
1029                .get()
1030                .state
1031                .unwrap()
1032                .with(FloatingActionButtonMenuState::is_expanded)
1033        );
1034        for _ in 0..100 {
1035            frame += 1;
1036            pump_layout_test_session(&mut session, frame * 16_666_667);
1037        }
1038        assert_eq!(probe.get().background.unwrap().get(), 8);
1039        let snapshot = click(&mut session, &mut frame, 350.0, 232.0);
1040        assert!(
1041            probe
1042                .get()
1043                .state
1044                .unwrap()
1045                .with(FloatingActionButtonMenuState::is_expanded),
1046            "disabled action must not dismiss"
1047        );
1048        let bounds = snapshot.node("fab-menu-item-2").bounds();
1049        click(
1050            &mut session,
1051            &mut frame,
1052            bounds.x.to_f32() + 50.0,
1053            bounds.y.to_f32() + 28.0,
1054        );
1055        assert_eq!(probe.get().action.unwrap().get(), 3);
1056        assert!(
1057            !probe
1058                .get()
1059                .state
1060                .unwrap()
1061                .with(FloatingActionButtonMenuState::is_expanded)
1062        );
1063        probe
1064            .get()
1065            .state
1066            .unwrap()
1067            .with_mut(FloatingActionButtonMenuState::open);
1068        frame += 1;
1069        pump_layout_test_session(&mut session, frame * 16_666_667);
1070        click(&mut session, &mut frame, 60.0, 24.0);
1071        assert!(
1072            !probe
1073                .get()
1074                .state
1075                .unwrap()
1076                .with(FloatingActionButtonMenuState::is_expanded)
1077        );
1078        assert_eq!(
1079            probe.get().background.unwrap().get(),
1080            8,
1081            "dismissal must not click through"
1082        );
1083    }
1084
1085    #[test]
1086    #[ignore = "requires a native GPU adapter"]
1087    fn rendered_menu_restores_pixels_and_exposes_disclosure_state() {
1088        let probe = tessera_ui::testing::TestProbe::<FixtureHandles>::default();
1089        let observed = probe.clone();
1090
1091        use tessera_ui::{HeadlessConfig, HeadlessRenderer};
1092
1093        fn disclosure_states(renderer: &HeadlessRenderer) -> Vec<bool> {
1094            let tree = renderer
1095                .accessibility_snapshot()
1096                .expect("accessibility tree");
1097            let mut states = Vec::new();
1098            for (_, node) in &tree.nodes {
1099                if let Some(expanded) = node.is_expanded() {
1100                    assert_eq!(node.has_popup(), Some(HasPopup::Menu));
1101                    states.push(expanded);
1102                }
1103            }
1104            assert_eq!(
1105                tree.nodes
1106                    .iter()
1107                    .filter(|(_, node)| node.role() == Role::MenuItem)
1108                    .count(),
1109                3,
1110                "menu wrappers must preserve action semantics"
1111            );
1112            let menu = tree
1113                .nodes
1114                .iter()
1115                .find(|(_, node)| node.role() == Role::Menu)
1116                .expect("menu semantic group");
1117            assert_eq!(menu.1.is_hidden(), !states[0]);
1118            states
1119        }
1120
1121        let mut renderer = HeadlessRenderer::new(
1122            Box::new(move || {
1123                fixture(true, FloatingActionButtonMenuAlignment::End, 3).probe(observed.clone());
1124            }),
1125            vec![Box::new(crate::TesseraComponents)],
1126            HeadlessConfig {
1127                width: 400,
1128                height: 400,
1129                ..Default::default()
1130            },
1131        )
1132        .expect("GPU renderer");
1133        renderer.render_frames(3);
1134        let expanded = renderer.read_pixels().unwrap();
1135        let corner = (335 * 400 + 335) * 4;
1136        let interior = (356 * 400 + 335) * 4;
1137        let contrast: u32 = (0..3)
1138            .map(|channel| expanded[corner + channel].abs_diff(expanded[interior + channel]) as u32)
1139            .sum();
1140        assert!(
1141            contrast > 32,
1142            "the expanded close button must have circular corners instead of a squircle"
1143        );
1144        assert_eq!(disclosure_states(&renderer), vec![true]);
1145        probe
1146            .get()
1147            .state
1148            .unwrap()
1149            .with_mut(FloatingActionButtonMenuState::close);
1150        renderer.render_frames(120);
1151        assert_ne!(expanded, renderer.read_pixels().unwrap());
1152        assert_eq!(disclosure_states(&renderer), vec![false]);
1153        probe
1154            .get()
1155            .state
1156            .unwrap()
1157            .with_mut(FloatingActionButtonMenuState::open);
1158        renderer.render_frames(120);
1159        let reopened = renderer.read_pixels().unwrap();
1160        crate::test_utils::assert_pixels_near(&expanded, &reopened, 400);
1161    }
1162}