1use 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
38pub struct FloatingActionButtonMenuState {
39 expanded: bool,
40}
41
42impl FloatingActionButtonMenuState {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn is_expanded(&self) -> bool {
50 self.expanded
51 }
52
53 pub fn open(&mut self) {
55 self.expanded = true;
56 }
57
58 pub fn close(&mut self) {
60 self.expanded = false;
61 }
62
63 pub fn toggle(&mut self) {
65 self.expanded = !self.expanded;
66 }
67}
68
69#[derive(Clone, Default, PartialEq)]
71pub struct FloatingActionButtonMenuItem {
72 pub label: String,
74 pub icon: Option<Painter>,
76 pub on_click: Option<Callback>,
78}
79
80impl FloatingActionButtonMenuItem {
81 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum FloatingActionButtonMenuColor {
94 #[default]
96 Primary,
97 Secondary,
99 Tertiary,
101}
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum FloatingActionButtonMenuAlignment {
106 Start,
108 #[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#[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 != ¤t) {
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}