Skip to main content

tessera_components/
navigation_rail.rs

1//! Material Design 3 navigation rail with collapsed and expanded layouts.
2//!
3//! ## Usage
4//!
5//! Use for primary destinations on wide layouts with a collapsible side rail.
6use std::time::Duration;
7
8use tessera_foundation::gesture::TapRecognizer;
9use tessera_ui::{
10    AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusProperties, FocusRequester,
11    FocusScopeNode, FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
12    PxSize, RenderSlot, State,
13    accesskit::Role,
14    current_frame_nanos,
15    layout::{MeasureScope, layout},
16    modifier::FocusModifierExt as _,
17    provide_context, receive_frame_nanos, remember, tessera, use_context,
18};
19
20use crate::{
21    alignment::{CrossAxisAlignment, MainAxisAlignment},
22    animation::{
23        self, SelectionAnimation,
24        visibility_motion::{VisibilityMotion, modal_back_handler},
25    },
26    badge::BadgeDefaults,
27    column::column,
28    modal_focus::{ModalFocusState, modal_focus_modifier},
29    modifier::{
30        InteractionState, ModifierExt, Padding, PointerEventContext, SelectableArgs, SemanticsArgs,
31        with_keyboard_input,
32    },
33    navigation_bar::interpolate_color,
34    overlay_semantics::{dialog_semantics, overlay_background},
35    ripple_state::{RippleSpec, RippleState},
36    row::row,
37    shape_def::{RoundedCorner, Shape},
38    surface::{SurfaceStyle, surface},
39    text::text,
40    theme::{ContentColor, MaterialTheme},
41};
42
43#[cfg(test)]
44#[path = "navigation_destination_tests.rs"]
45mod destination_tests;
46
47const ANIMATION_DURATION: Duration = Duration::from_millis(300);
48const COLLAPSED_WIDTH: Dp = Dp(96.0);
49const EXPANDED_MIN_WIDTH: Dp = Dp(220.0);
50const TOP_PADDING: Dp = Dp(36.0);
51const HEADER_BOTTOM_PADDING: Dp = Dp(40.0);
52const ITEM_HORIZONTAL_PADDING: Dp = Dp(20.0);
53const ITEM_VERTICAL_SPACING_COLLAPSED: Dp = Dp(4.0);
54const ITEM_VERTICAL_SPACING_EXPANDED: Dp = Dp(0.0);
55const TOP_ICON_ITEM_MIN_HEIGHT: Dp = Dp(56.0);
56const START_ICON_ITEM_MIN_HEIGHT: Dp = Dp(56.0);
57const INDICATOR_TOP_WIDTH: Dp = Dp(56.0);
58const INDICATOR_TOP_HEIGHT: Dp = Dp(32.0);
59const INDICATOR_START_HEIGHT: Dp = Dp(56.0);
60const INDICATOR_TOP_TO_LABEL_PADDING: Dp = Dp(4.0);
61const START_ICON_TO_LABEL_PADDING: Dp = Dp(8.0);
62const TOP_ICON_INDICATOR_VERTICAL_PADDING: Dp = Dp(4.0);
63const START_ICON_INDICATOR_HORIZONTAL_PADDING: Dp = Dp(16.0);
64
65fn lerp_dp(from: Dp, to: Dp, progress: f32) -> Dp {
66    let t = f64::from(progress.clamp(0.0, 1.0));
67    Dp(from.0 + (to.0 - from.0) * t)
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71enum NavigationRailIconPosition {
72    #[default]
73    Top,
74    Start,
75}
76
77/// Layout configuration supplied by a navigation rail content slot.
78#[derive(Clone, Copy, PartialEq)]
79pub struct NavigationRailScope {
80    icon_position: NavigationRailIconPosition,
81    indicator_start_width: Dp,
82    item_min_height: Dp,
83    item_spacing: Dp,
84}
85
86#[tessera]
87fn navigation_rail_item_view_content(
88    item: Option<NavigationRailItemDefinition>,
89    icon_position: Option<NavigationRailIconPosition>,
90    selection_fraction: Option<f32>,
91    item_min_height: Option<Dp>,
92    interaction_state: Option<State<InteractionState>>,
93    ripple_state: Option<State<RippleState>>,
94    indicator_geometry: Option<State<RailRippleGeometry>>,
95    focus_requester: Option<FocusRequester>,
96) {
97    let item = item.unwrap_or_default();
98    let icon_position = icon_position.unwrap_or_default();
99    let selection_fraction = selection_fraction.unwrap_or(0.0);
100    let item_min_height = item_min_height.unwrap_or(Dp(0.0));
101    let interaction_state = interaction_state.expect("interaction_state must be set");
102    let ripple_state = ripple_state.expect("ripple_state must be set");
103    let focus_requester = focus_requester.expect("focus_requester must be set");
104    let theme = use_context::<MaterialTheme>()
105        .expect("MaterialTheme must be provided")
106        .get();
107    let scheme = theme.color_scheme;
108    let typography = theme.typography;
109
110    let has_label = !item.label.is_empty();
111    let has_icon = item.icon.is_some();
112    let has_badge = item.badge.is_some();
113
114    let icon_color = interpolate_color(
115        scheme.on_surface_variant,
116        scheme.on_secondary_container,
117        selection_fraction,
118    );
119    let label_color = interpolate_color(
120        scheme.on_surface_variant,
121        if icon_position == NavigationRailIconPosition::Start {
122            scheme.on_secondary_container
123        } else {
124            scheme.secondary
125        },
126        selection_fraction,
127    );
128    let ripple_color = icon_color;
129    let indicator_color = scheme.secondary_container.with_alpha(selection_fraction);
130
131    layout()
132        .policy(NavigationRailItemLayout {
133            icon_position,
134            has_label,
135            has_icon,
136            has_badge,
137            selection_fraction,
138            item_min_height,
139        })
140        .child(move || {
141            surface()
142                .style(SurfaceStyle::Filled {
143                    color: indicator_color,
144                })
145                .shape(Shape::CAPSULE)
146                .show_state_layer(false)
147                .show_ripple(false)
148                .child(|| {});
149
150            surface()
151                .style(SurfaceStyle::Filled {
152                    color: Color::TRANSPARENT,
153                })
154                .shape(Shape::CAPSULE)
155                .modifier(Modifier::new().on_layout_coordinates(
156                    move |bounds: tessera_ui::LayoutCoordinates| {
157                        if let Some(indicator_geometry) = indicator_geometry {
158                            if indicator_geometry.with(|geometry| geometry.indicator)
159                                != Some(bounds)
160                            {
161                                indicator_geometry
162                                    .with_mut(|geometry| geometry.indicator = Some(bounds));
163                            }
164                        }
165                    },
166                ))
167                .enabled(true)
168                .interaction_state(interaction_state)
169                .focus_requester(focus_requester)
170                .show_focus_indicator(true)
171                .ripple_color(ripple_color)
172                .child(move || {
173                    surface()
174                        .style(SurfaceStyle::Filled {
175                            color: Color::TRANSPARENT,
176                        })
177                        .shape(Shape::CAPSULE)
178                        .modifier(Modifier::new().fill_max_size())
179                        .enabled(true)
180                        .ripple_color(ripple_color)
181                        .show_state_layer(false)
182                        .ripple_state(ripple_state)
183                        .child(|| {});
184                });
185
186            if let Some(draw_icon) = item.icon {
187                provide_context(
188                    || ContentColor {
189                        current: icon_color,
190                    },
191                    || {
192                        let color = use_context::<ContentColor>().unwrap();
193                        if color.with(|value| value.current) != icon_color {
194                            color.set(ContentColor {
195                                current: icon_color,
196                            });
197                        }
198                        draw_icon.render();
199                    },
200                );
201            }
202
203            if has_label {
204                let label = item.label.clone();
205                let label_style = match icon_position {
206                    NavigationRailIconPosition::Top => typography.label_medium,
207                    NavigationRailIconPosition::Start => typography.label_large,
208                };
209                provide_context(
210                    || label_style,
211                    move || {
212                        text(label.clone()).color(label_color).style(label_style);
213                    },
214                );
215            }
216            if let Some(badge) = item.badge {
217                crate::boxed::boxed().children_shared(badge);
218            }
219        });
220}
221
222#[derive(Clone, PartialEq)]
223struct NavigationRailItemLayout {
224    icon_position: NavigationRailIconPosition,
225    has_label: bool,
226    has_icon: bool,
227    has_badge: bool,
228    selection_fraction: f32,
229    item_min_height: Dp,
230}
231
232impl tessera_ui::LayoutModifierNode for NavigationRailItemLayout {
233    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
234        let mut result = LayoutResult::default();
235        let children = input.children();
236        let parent_width = input
237            .parent_constraint()
238            .width()
239            .resolve_max()
240            .unwrap_or(Px::ZERO);
241        let min_height = self.item_min_height.to_px();
242
243        let indicator_background = children[0];
244        let indicator_ripple = children[1];
245        let mut child_index = 2;
246
247        let icon_id = if self.has_icon {
248            let id = children[child_index];
249            child_index += 1;
250            Some(id)
251        } else {
252            None
253        };
254
255        let label_id = if self.has_label {
256            let id = children[child_index];
257            child_index += 1;
258            Some(id)
259        } else {
260            None
261        };
262
263        let child_constraint = Constraint::NONE;
264        let icon_size = icon_id
265            .map(|id| id.measure(input, &child_constraint))
266            .transpose()?
267            .map(|size| size.size())
268            .unwrap_or(ComputedData::ZERO);
269        let badge_id = self.has_badge.then(|| children[child_index]);
270        let badge_size = badge_id
271            .map(|id| id.measure(input, &child_constraint))
272            .transpose()?
273            .map(|size| size.size())
274            .unwrap_or(ComputedData::ZERO);
275        let label_size = label_id
276            .map(|id| {
277                id.measure(
278                    input,
279                    &Constraint::new(
280                        AxisConstraint::new(
281                            Px::ZERO,
282                            Some(
283                                (parent_width
284                                    - if self.icon_position == NavigationRailIconPosition::Start {
285                                        ITEM_HORIZONTAL_PADDING.to_px() * 2
286                                            + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2
287                                            + icon_size.width
288                                            + if self.has_icon {
289                                                START_ICON_TO_LABEL_PADDING.to_px()
290                                            } else {
291                                                Px::ZERO
292                                            }
293                                            + badge_size.width
294                                            + if self.has_badge {
295                                                Dp(8.0).to_px()
296                                            } else {
297                                                Px::ZERO
298                                            }
299                                    } else {
300                                        Dp(8.0).to_px()
301                                    })
302                                .max(Px::ZERO),
303                            ),
304                        ),
305                        AxisConstraint::NONE,
306                    ),
307                )
308            })
309            .transpose()?
310            .map(|size| size.size())
311            .unwrap_or(ComputedData::ZERO);
312
313        let expanded = self.icon_position == NavigationRailIconPosition::Start;
314        let label_gap = if self.has_icon && self.has_label {
315            START_ICON_TO_LABEL_PADDING.to_px()
316        } else {
317            Px::ZERO
318        };
319        let badge_gap = if self.has_badge && (self.has_icon || self.has_label) {
320            Dp(8.0).to_px()
321        } else {
322            Px::ZERO
323        };
324        let content_width =
325            icon_size.width + label_gap + label_size.width + badge_gap + badge_size.width;
326        let base_width = if expanded {
327            (content_width + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2)
328                .min((parent_width - ITEM_HORIZONTAL_PADDING.to_px() * 2).max(Px::ZERO))
329        } else {
330            INDICATOR_TOP_WIDTH.to_px()
331        };
332        let indicator_height = if expanded {
333            INDICATOR_START_HEIGHT.to_px().max(
334                icon_size
335                    .height
336                    .max(label_size.height)
337                    .max(badge_size.height)
338                    + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2,
339            )
340        } else {
341            INDICATOR_TOP_HEIGHT.to_px()
342        };
343        let animated_width =
344            Px((base_width.0 as f32 * self.selection_fraction.clamp(0.0, 1.0)).round() as i32);
345        let indicator_size = indicator_background
346            .measure(
347                input,
348                &Constraint::new(
349                    AxisConstraint::exact(animated_width),
350                    AxisConstraint::exact(indicator_height),
351                ),
352            )?
353            .size();
354        let indicator_ripple_size = indicator_ripple
355            .measure(
356                input,
357                &Constraint::new(
358                    AxisConstraint::exact(base_width),
359                    AxisConstraint::exact(indicator_height),
360                ),
361            )?
362            .size();
363        let width = parent_width;
364        let content_height = if expanded {
365            icon_size
366                .height
367                .max(label_size.height)
368                .max(badge_size.height)
369                + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2
370        } else if self.has_label {
371            icon_size.height
372                + label_size.height
373                + TOP_ICON_INDICATOR_VERTICAL_PADDING.to_px() * 3
374                + INDICATOR_TOP_TO_LABEL_PADDING.to_px()
375        } else {
376            icon_size.height.max(INDICATOR_TOP_HEIGHT.to_px())
377        };
378        let height = input
379            .parent_constraint()
380            .height()
381            .clamp(min_height.max(content_height));
382
383        match self.icon_position {
384            NavigationRailIconPosition::Start => {
385                let horizontal_padding = ITEM_HORIZONTAL_PADDING.to_px();
386                let ripple_x = horizontal_padding;
387                let ripple_y = (height - indicator_ripple_size.height) / 2;
388                result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
389
390                let indicator_x =
391                    ripple_x + (indicator_ripple_size.width - indicator_size.width) / 2;
392                let indicator_y = (height - indicator_size.height) / 2;
393                result.place_child(
394                    indicator_background,
395                    PxPosition::new(indicator_x, indicator_y),
396                );
397
398                let content_x =
399                    horizontal_padding + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px();
400
401                if let Some(icon_id) = icon_id {
402                    let icon_y = (height - icon_size.height) / 2;
403                    result.place_child(icon_id, PxPosition::new(content_x, icon_y));
404                }
405
406                if let Some(label_id) = label_id {
407                    let label_y = (height - label_size.height) / 2;
408                    let label_x = if self.has_icon {
409                        content_x + icon_size.width + START_ICON_TO_LABEL_PADDING.to_px()
410                    } else {
411                        content_x
412                    };
413                    result.place_child(label_id, PxPosition::new(label_x, label_y));
414                }
415
416                if let Some(badge_id) = badge_id {
417                    let badge_x =
418                        content_x + icon_size.width + label_gap + label_size.width + badge_gap;
419                    result.place_child(
420                        badge_id,
421                        PxPosition::new(badge_x, (height - badge_size.height) / 2),
422                    );
423                }
424
425                return Ok(result.with_size(ComputedData { width, height }));
426            }
427            NavigationRailIconPosition::Top => {}
428        }
429
430        if !self.has_label {
431            let ripple_x = (width - indicator_ripple_size.width) / 2;
432            let ripple_y = (height - indicator_ripple_size.height) / 2;
433            let indicator_x = (width - indicator_size.width) / 2;
434            let indicator_y = (height - indicator_size.height) / 2;
435            result.place_child(
436                indicator_background,
437                PxPosition::new(indicator_x, indicator_y),
438            );
439            result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
440
441            if let Some(icon_id) = icon_id {
442                let icon_x = (width - icon_size.width) / 2;
443                let icon_y = (height - icon_size.height) / 2;
444                result.place_child(icon_id, PxPosition::new(icon_x, icon_y));
445            }
446
447            if let Some(badge_id) = badge_id {
448                let large = badge_size.width > BadgeDefaults::SIZE.to_px();
449                let dx = if large {
450                    BadgeDefaults::WITH_CONTENT_HORIZONTAL_OFFSET
451                } else {
452                    BadgeDefaults::OFFSET
453                }
454                .to_px();
455                let dy = if large {
456                    BadgeDefaults::WITH_CONTENT_VERTICAL_OFFSET
457                } else {
458                    BadgeDefaults::OFFSET
459                }
460                .to_px();
461                result.place_child(
462                    badge_id,
463                    PxPosition::new(
464                        (width + icon_size.width) / 2 - dx,
465                        (height - icon_size.height) / 2 - badge_size.height + dy,
466                    ),
467                );
468            }
469
470            return Ok(result.with_size(ComputedData { width, height }));
471        }
472
473        let indicator_vertical_padding_px = TOP_ICON_INDICATOR_VERTICAL_PADDING.to_px();
474        let content_height = icon_size.height
475            + indicator_vertical_padding_px
476            + INDICATOR_TOP_TO_LABEL_PADDING.to_px()
477            + label_size.height;
478        let content_vertical_padding =
479            ((height - content_height) / 2).max(indicator_vertical_padding_px);
480
481        let icon_x = (width - icon_size.width) / 2;
482        let label_x = (width - label_size.width) / 2;
483        let indicator_x = (width - indicator_size.width) / 2;
484        let ripple_x = (width - indicator_ripple_size.width) / 2;
485
486        let indicator_y = content_vertical_padding - indicator_vertical_padding_px;
487        let icon_y = content_vertical_padding;
488        let ripple_y = indicator_y;
489        let label_y = content_vertical_padding
490            + icon_size.height
491            + indicator_vertical_padding_px
492            + INDICATOR_TOP_TO_LABEL_PADDING.to_px();
493
494        result.place_child(
495            indicator_background,
496            PxPosition::new(indicator_x, indicator_y),
497        );
498        result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
499
500        if let Some(icon_id) = icon_id {
501            result.place_child(icon_id, PxPosition::new(icon_x, icon_y));
502        }
503
504        if let Some(label_id) = label_id {
505            result.place_child(label_id, PxPosition::new(label_x, label_y));
506        }
507
508        if let Some(badge_id) = badge_id {
509            let large = badge_size.width > BadgeDefaults::SIZE.to_px();
510            let dx = if large {
511                BadgeDefaults::WITH_CONTENT_HORIZONTAL_OFFSET
512            } else {
513                BadgeDefaults::OFFSET
514            }
515            .to_px();
516            let dy = if large {
517                BadgeDefaults::WITH_CONTENT_VERTICAL_OFFSET
518            } else {
519                BadgeDefaults::OFFSET
520            }
521            .to_px();
522            result.place_child(
523                badge_id,
524                PxPosition::new(
525                    icon_x + icon_size.width - dx,
526                    icon_y - badge_size.height + dy,
527                ),
528            );
529        }
530
531        Ok(result.with_size(ComputedData { width, height }))
532    }
533}
534
535#[tessera]
536fn navigation_rail_item_view(
537    item: Option<NavigationRailItemDefinition>,
538    selected: Option<bool>,
539    selection_fraction: Option<f32>,
540    icon_position: Option<NavigationRailIconPosition>,
541    indicator_start_width: Option<Dp>,
542    item_min_height: Option<Dp>,
543) {
544    let item = item.unwrap_or_default();
545    let selected = selected.unwrap_or(false);
546    let selection_fraction = selection_fraction.unwrap_or(0.0);
547    let icon_position = icon_position.unwrap_or_default();
548    let indicator_start_width = indicator_start_width.unwrap_or(Dp(0.0));
549    let item_min_height = item_min_height.unwrap_or(Dp(0.0));
550    let interaction_state = remember(InteractionState::new);
551    let ripple_state = remember(RippleState::new);
552    let tap_recognizer = remember(TapRecognizer::default);
553    let focus = remember(FocusRequester::new).get();
554
555    let label = item.label.clone();
556    let description = item.accessibility_description.clone();
557    let on_click = item.on_click;
558    let ripple_size = match icon_position {
559        NavigationRailIconPosition::Top => {
560            PxSize::new(INDICATOR_TOP_WIDTH.to_px(), INDICATOR_TOP_HEIGHT.to_px())
561        }
562        NavigationRailIconPosition::Start => PxSize::new(
563            indicator_start_width.to_px(),
564            INDICATOR_START_HEIGHT.to_px(),
565        ),
566    };
567
568    let indicator_geometry = remember(RailRippleGeometry::default);
569    let on_press = {
570        move |ctx: PointerEventContext| {
571            let spec = RippleSpec {
572                bounded: true,
573                radius: None,
574            };
575            ripple_state.with_mut(|state| {
576                let geometry = indicator_geometry.get();
577                let (position, size) = if let (Some(target), Some(indicator)) =
578                    (geometry.target, geometry.indicator)
579                {
580                    let point = target.local_to_window.map_point(PxPosition::from_f32_xy(
581                        ctx.normalized_pos[0] * ctx.size.width.to_f32(),
582                        ctx.normalized_pos[1] * ctx.size.height.to_f32(),
583                    ));
584                    let point = indicator.local_to_window.inverse_point(point);
585                    (
586                        [
587                            point.x / indicator.local_size.width.to_f32().max(1.0),
588                            point.y / indicator.local_size.height.to_f32().max(1.0),
589                        ],
590                        indicator.local_size,
591                    )
592                } else {
593                    (ctx.normalized_pos, ripple_size)
594                };
595                state.start_animation_with_spec(position, size, spec);
596            });
597        }
598    };
599    let on_release = {
600        move |_ctx: PointerEventContext| {
601            ripple_state.with_mut(|state| state.release());
602        }
603    };
604
605    let selectable_args = SelectableArgs {
606        selected,
607        on_click,
608        enabled: true,
609        role: Some(Role::Tab),
610        label: Some(label),
611        description,
612        interaction_state: Some(interaction_state),
613        focus_requester: Some(focus),
614        on_press: Some(on_press.into()),
615        on_release: Some(on_release.into()),
616        tap_recognizer: Some(tap_recognizer),
617        ..Default::default()
618    };
619
620    let modifier = Modifier::new()
621        .selectable_with(selectable_args)
622        .on_layout_coordinates(move |bounds: tessera_ui::LayoutCoordinates| {
623            if indicator_geometry.with(|geometry| geometry.target) != Some(bounds) {
624                indicator_geometry.with_mut(|geometry| geometry.target = Some(bounds));
625            }
626        });
627    layout().modifier(modifier).child({
628        let item = item.clone();
629        move || {
630            navigation_rail_item_view_content()
631                .item(item.clone())
632                .icon_position(icon_position)
633                .selection_fraction(selection_fraction)
634                .item_min_height(item_min_height)
635                .interaction_state(interaction_state)
636                .ripple_state(ripple_state)
637                .indicator_geometry(indicator_geometry)
638                .focus_requester(focus);
639        }
640    });
641}
642
643#[derive(Clone, Copy, Default, PartialEq)]
644struct RailRippleGeometry {
645    target: Option<tessera_ui::LayoutCoordinates>,
646    indicator: Option<tessera_ui::LayoutCoordinates>,
647}
648
649#[derive(Clone, PartialEq, Default)]
650struct NavigationRailItemDefinition {
651    label: String,
652    icon: Option<RenderSlot>,
653    badge: Option<RenderSlot>,
654    accessibility_description: Option<String>,
655    on_click: Callback,
656}
657
658/// # navigation_rail_item
659///
660/// Renders a single destination inside [`navigation_rail`].
661///
662/// ## Usage
663///
664/// Declare one primary destination in a navigation rail content slot.
665///
666/// ## Parameters
667///
668/// - `rail` — layout configuration supplied by the parent content slot.
669/// - `label` — text label shown next to or below the icon.
670/// - `icon` — optional icon rendered for the item.
671/// - `badge` — optional update marker, overlapping a collapsed icon or
672///   following an expanded label.
673/// - `accessibility_description` — additional announcement such as a
674///   notification count.
675/// - `selected` — whether this item is the active destination.
676/// - `on_click` — callback invoked after the item becomes selected.
677///
678/// ## Examples
679///
680/// ```
681/// use tessera_components::navigation_rail::{navigation_rail, navigation_rail_item};
682/// use tessera_ui::{remember, tessera};
683///
684/// #[tessera]
685/// fn demo() {
686///     let selected = remember(|| 0usize);
687///     navigation_rail().content(move |rail| {
688///         navigation_rail_item(rail)
689///             .label("Home")
690///             .selected(selected.get() == 0)
691///             .on_click(move || selected.set(0));
692///     });
693/// }
694/// ```
695#[tessera]
696pub fn navigation_rail_item(
697    rail: NavigationRailScope,
698    #[prop(into)] label: Option<String>,
699    icon: Option<RenderSlot>,
700    on_click: Option<Callback>,
701    selected: Option<bool>,
702    badge: Option<RenderSlot>,
703    #[prop(into)] accessibility_description: Option<String>,
704) {
705    let label = label.unwrap_or_default();
706    let on_click = on_click.unwrap_or_default();
707    let selected = selected.unwrap_or(false);
708    let composition = rail;
709
710    // Each item owns its own selection animation, keyed by call position, so
711    // there is no shared index registry that could drift across rebuilds.
712    let animation = remember(|| SelectionAnimation::new(selected));
713    let frame_nanos = current_frame_nanos();
714    animation.with_mut(|state| state.set_selected(selected));
715    let selection_fraction = animation.with_mut(|state| {
716        state.update(frame_nanos);
717        state.progress()
718    });
719    if animation.with(|state| state.is_animating()) {
720        receive_frame_nanos(move |frame_nanos| {
721            let is_animating = animation.with_mut(|state| {
722                state.update(frame_nanos);
723                state.is_animating()
724            });
725            if is_animating {
726                tessera_ui::FrameNanosControl::Continue
727            } else {
728                tessera_ui::FrameNanosControl::Stop
729            }
730        });
731    }
732
733    let item_spacing = composition.item_spacing;
734    layout()
735        .modifier(Modifier::new().padding(Padding::new(Dp::ZERO, Dp::ZERO, Dp::ZERO, item_spacing)))
736        .child(move || {
737            let item = NavigationRailItemDefinition {
738                label: label.clone(),
739                icon,
740                badge,
741                accessibility_description: accessibility_description.clone(),
742                on_click,
743            };
744            navigation_rail_item_view()
745                .item(item)
746                .selected(selected)
747                .selection_fraction(selection_fraction)
748                .icon_position(composition.icon_position)
749                .indicator_start_width(composition.indicator_start_width)
750                .item_min_height(composition.item_min_height);
751        });
752}
753
754/// # navigation_rail
755///
756/// Navigation rail that switches between collapsed and expanded layouts for
757/// primary destinations.
758///
759/// ## Usage
760///
761/// Use for tablet and desktop layouts with 3-7 primary destinations.
762///
763/// ## Parameters
764///
765/// - `expanded` — whether the rail renders in expanded (icon-start) mode.
766/// - `content` — item declarations rendered in the rail.
767/// - `header` — optional header rendered above items.
768///
769/// ## Examples
770///
771/// ```
772/// use tessera_components::navigation_rail::{navigation_rail, navigation_rail_item};
773/// use tessera_ui::{remember, tessera};
774///
775/// #[tessera]
776/// fn demo() {
777///     let selected = remember(|| 0usize);
778///     navigation_rail().expanded(true).content(move |rail| {
779///         navigation_rail_item(rail)
780///             .label("Home")
781///             .selected(selected.get() == 0)
782///             .on_click(move || selected.set(0));
783///     });
784/// }
785/// ```
786#[tessera]
787pub fn navigation_rail(
788    expanded: Option<bool>,
789    content: Option<tessera_ui::RenderSlotWith<NavigationRailScope>>,
790    header: Option<RenderSlot>,
791) {
792    let expanded = expanded.unwrap_or(false);
793    let scheme = use_context::<MaterialTheme>()
794        .expect("MaterialTheme must be provided")
795        .get()
796        .color_scheme;
797    let controller = remember(NavigationRailExpansion::default);
798    let frame_nanos = current_frame_nanos();
799    controller.with_mut(|c| c.set_expanded(expanded));
800    let expand_fraction = controller.with_mut(|c| {
801        c.update(frame_nanos);
802        c.expand_fraction()
803    });
804    if controller.with(|c| c.is_animating()) {
805        receive_frame_nanos(move |frame_nanos| {
806            let is_animating = controller.with_mut(|c| {
807                c.update(frame_nanos);
808                c.is_animating()
809            });
810            if is_animating {
811                tessera_ui::FrameNanosControl::Continue
812            } else {
813                tessera_ui::FrameNanosControl::Stop
814            }
815        });
816    }
817
818    let icon_position = if expand_fraction >= 0.5 {
819        NavigationRailIconPosition::Start
820    } else {
821        NavigationRailIconPosition::Top
822    };
823
824    let container_width = lerp_dp(COLLAPSED_WIDTH, EXPANDED_MIN_WIDTH, expand_fraction);
825    let item_min_height = match icon_position {
826        NavigationRailIconPosition::Top => TOP_ICON_ITEM_MIN_HEIGHT,
827        NavigationRailIconPosition::Start => START_ICON_ITEM_MIN_HEIGHT,
828    };
829    let item_spacing = match icon_position {
830        NavigationRailIconPosition::Top => ITEM_VERTICAL_SPACING_COLLAPSED,
831        NavigationRailIconPosition::Start => ITEM_VERTICAL_SPACING_EXPANDED,
832    };
833    let indicator_start_width =
834        Dp((container_width.0 - ITEM_HORIZONTAL_PADDING.0 * 2.0).max(INDICATOR_TOP_WIDTH.0));
835    let composition = NavigationRailScope {
836        icon_position,
837        indicator_start_width,
838        item_min_height,
839        item_spacing,
840    };
841
842    let modifier = Modifier::new()
843        .focus_group()
844        .focus_traversal_policy(FocusTraversalPolicy::vertical().wrap(true));
845    layout().modifier(modifier).child({
846        let composition = composition;
847        move || {
848            let composition = composition;
849            surface()
850                .modifier(Modifier::new().fill_max_height().width(container_width))
851                .style(scheme.surface.into())
852                .block_input(true)
853                .child(move || {
854                    navigation_rail_content()
855                        .composition(composition)
856                        .header_optional(header)
857                        .content_optional(content);
858                });
859        }
860    });
861}
862
863#[tessera]
864fn navigation_rail_content(
865    composition: Option<NavigationRailScope>,
866    header: Option<RenderSlot>,
867    content: Option<tessera_ui::RenderSlotWith<NavigationRailScope>>,
868) {
869    let content_context = composition.expect("rail content requires item composition");
870    column()
871        .modifier(Modifier::new().fill_max_size().padding(Padding::new(
872            Dp::ZERO,
873            TOP_PADDING,
874            Dp::ZERO,
875            Dp::ZERO,
876        )))
877        .main_axis_alignment(MainAxisAlignment::Start)
878        .cross_axis_alignment(CrossAxisAlignment::Start)
879        .children(move || {
880            if let Some(header) = header {
881                row()
882                    .modifier(Modifier::new().padding(Padding::new(
883                        ITEM_HORIZONTAL_PADDING,
884                        Dp::ZERO,
885                        Dp::ZERO,
886                        Dp::ZERO,
887                    )))
888                    .children(move || {
889                        header.render();
890                    });
891                layout().modifier(Modifier::new().height(HEADER_BOTTOM_PADDING));
892            }
893
894            if let Some(content) = content {
895                content.render(content_context);
896            }
897        });
898}
899
900/// # modal_navigation_rail
901///
902/// Show an expanded navigation rail above page content for space-constrained
903/// navigation.
904///
905/// ## Usage
906///
907/// Open primary and secondary destinations from a menu without moving the page
908/// layout.
909///
910/// ## Parameters
911///
912/// - `is_open` — whether the modal rail is visible; defaults to false.
913/// - `on_close_request` — dismissal callback for the scrim and Escape key.
914/// - `accessibility_label` — accessible name for the modal navigation dialog.
915/// - `main_content` — page content displayed behind the rail.
916/// - `header` — optional menu or action displayed above destinations.
917/// - `content` — expanded destination items; close the rail in their activation
918///   callbacks.
919///
920/// ## Examples
921///
922/// ```
923/// use tessera_components::{
924///     button::button,
925///     navigation_rail::{modal_navigation_rail, navigation_rail_item},
926///     text::text,
927///     theme::material_theme,
928/// };
929/// use tessera_ui::{remember, tessera};
930///
931/// #[tessera]
932/// fn navigation() {
933///     let open = remember(|| false);
934///     let selected = remember(|| 0usize);
935///     material_theme().child(move || {
936///         modal_navigation_rail()
937///             .is_open(open.get())
938///             .accessibility_label("Mail navigation")
939///             .on_close_request(move || open.set(false))
940///             .main_content(move || {
941///                 button().on_click(move || open.set(true)).child(|| {
942///                     text("Open navigation");
943///                 });
944///             })
945///             .content(move |rail| {
946///                 navigation_rail_item(rail)
947///                     .label("Inbox")
948///                     .selected(selected.get() == 0)
949///                     .on_click(move || {
950///                         selected.set(0);
951///                         open.set(false);
952///                     });
953///             });
954///     });
955/// }
956/// # tessera_ui::testing::layout_test(navigation).run();
957/// ```
958#[tessera]
959pub fn modal_navigation_rail(
960    is_open: Option<bool>,
961    on_close_request: Option<Callback>,
962    #[prop(into)] accessibility_label: Option<String>,
963    main_content: Option<RenderSlot>,
964    header: Option<RenderSlot>,
965    content: Option<tessera_ui::RenderSlotWith<NavigationRailScope>>,
966) {
967    let is_open = is_open.unwrap_or(false);
968    let on_close_request = on_close_request.unwrap_or_default();
969    let main_content = main_content.unwrap_or_default();
970    let scheme = use_context::<MaterialTheme>()
971        .expect("MaterialTheme must be provided")
972        .get()
973        .color_scheme;
974    let scope = remember(FocusScopeNode::new).get();
975    let focus = remember(ModalFocusState::default);
976    let was_open = remember(|| false);
977    let back_motion = remember(|| VisibilityMotion::new(true, true));
978    let just_opened = is_open && !was_open.get();
979    if was_open.get() != is_open {
980        was_open.set(is_open);
981        back_motion.set(VisibilityMotion::new(true, true));
982    }
983    let back = back_motion.get().sample(current_frame_nanos());
984    if back.active {
985        receive_frame_nanos(move |frame| {
986            if back_motion.with(|motion| motion.sample(frame).active) {
987                tessera_ui::FrameNanosControl::Continue
988            } else {
989                tessera_ui::FrameNanosControl::Stop
990            }
991        });
992    }
993    let modifier = modal_focus_modifier(Modifier::new(), focus, is_open, scope);
994    layout()
995        .modifier(modifier)
996        .policy(ModalNavigationRailLayout)
997        .child(move || {
998            overlay_background(main_content, is_open);
999            modal_back_handler(is_open, on_close_request).on_preview(move |event| {
1000                back_motion.with_mut(|motion| motion.preview_back(event, current_frame_nanos()));
1001            });
1002            if is_open {
1003                surface()
1004                    .style(scheme.scrim.with_alpha(0.32 * back.opacity).into())
1005                    .modifier(
1006                        Modifier::new()
1007                            .fill_max_size()
1008                            .semantics(SemanticsArgs {
1009                                hidden: true,
1010                                ..Default::default()
1011                            })
1012                            .focusable()
1013                            .focus_properties(
1014                                FocusProperties::new()
1015                                    .can_focus(false)
1016                                    .can_request_focus(false)
1017                                    .skip_traversal(true),
1018                            ),
1019                    )
1020                    .on_click_shared(on_close_request)
1021                    .show_ripple(false)
1022                    .show_state_layer(false)
1023                    .block_input(true)
1024                    .child(|| {});
1025                let modifier = dialog_semantics(
1026                    with_keyboard_input(
1027                        Modifier::new()
1028                            .focus_scope_with(scope)
1029                            .focus_properties(FocusProperties::new().skip_traversal(true))
1030                            .focus_traversal_policy(
1031                                FocusTraversalPolicy::linear()
1032                                    .wrap(true)
1033                                    .tab_navigation(true),
1034                            ),
1035                        Box::new(move |input: tessera_ui::KeyboardInput<'_>| {
1036                            input.keyboard_events.retain(|event| {
1037                                !modal_rail_key(event.physical_key, event.state, on_close_request)
1038                            });
1039                        }),
1040                    ),
1041                    accessibility_label.clone(),
1042                    true,
1043                );
1044                if just_opened {
1045                    scope.request_focus();
1046                }
1047                surface()
1048                    .modifier(
1049                        modifier
1050                            .fill_max_size()
1051                            .offset(Dp(-48.0 * f64::from(1.0 - back.position)), Dp::ZERO)
1052                            .alpha(back.opacity),
1053                    )
1054                    .style(scheme.surface_container_low.into())
1055                    .elevation(Dp(1.0))
1056                    .shape(Shape::RoundedRectangle {
1057                        top_left: RoundedCorner::ZERO,
1058                        top_right: RoundedCorner::manual(Dp(16.0), 2.0),
1059                        bottom_right: RoundedCorner::manual(Dp(16.0), 2.0),
1060                        bottom_left: RoundedCorner::ZERO,
1061                    })
1062                    .block_input(true)
1063                    .child(move || {
1064                        navigation_rail_content()
1065                            .composition(NavigationRailScope {
1066                                icon_position: NavigationRailIconPosition::Start,
1067                                indicator_start_width: Dp(
1068                                    EXPANDED_MIN_WIDTH.0 - ITEM_HORIZONTAL_PADDING.0 * 2.0
1069                                ),
1070                                item_min_height: START_ICON_ITEM_MIN_HEIGHT,
1071                                item_spacing: ITEM_VERTICAL_SPACING_EXPANDED,
1072                            })
1073                            .header_optional(header)
1074                            .content_optional(content);
1075                    });
1076            }
1077        });
1078}
1079
1080fn modal_rail_key(
1081    key: tessera_ui::winit::keyboard::PhysicalKey,
1082    state: tessera_ui::winit::event::ElementState,
1083    on_close: Callback,
1084) -> bool {
1085    if state == tessera_ui::winit::event::ElementState::Pressed
1086        && key
1087            == tessera_ui::winit::keyboard::PhysicalKey::Code(
1088                tessera_ui::winit::keyboard::KeyCode::Escape,
1089            )
1090    {
1091        on_close.call();
1092        true
1093    } else {
1094        false
1095    }
1096}
1097
1098#[derive(Clone, PartialEq)]
1099struct ModalNavigationRailLayout;
1100
1101impl tessera_ui::LayoutModifierNode for ModalNavigationRailLayout {
1102    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
1103        let children = scope.children();
1104        let main = children[0];
1105        let constraint = scope.parent_constraint().without_min();
1106        let main_size = main.measure(scope, &constraint)?;
1107        let size = ComputedData {
1108            width: scope.parent_constraint().width().clamp(main_size.width),
1109            height: scope.parent_constraint().height().clamp(main_size.height),
1110        };
1111        let mut result = LayoutResult::new(size);
1112        result.place_child(main, PxPosition::ZERO);
1113        if children.len() > 1 {
1114            children[1].measure(scope, &Constraint::exact(size.width, size.height))?;
1115            result.place_child(children[1], PxPosition::ZERO);
1116            children[2].measure(
1117                scope,
1118                &Constraint::exact(size.width.min(EXPANDED_MIN_WIDTH.to_px()), size.height),
1119            )?;
1120            result.place_child(children[2], PxPosition::ZERO);
1121        }
1122        Ok(result)
1123    }
1124}
1125
1126/// Expansion animation state for the navigation rail container.
1127#[derive(Clone, PartialEq, Default)]
1128struct NavigationRailExpansion {
1129    expanded: bool,
1130    start_frame_nanos: Option<u64>,
1131}
1132
1133impl NavigationRailExpansion {
1134    fn set_expanded(&mut self, expanded: bool) {
1135        if self.expanded != expanded {
1136            self.expanded = expanded;
1137            self.start_frame_nanos = Some(current_frame_nanos());
1138        }
1139    }
1140
1141    fn update(&mut self, frame_nanos: u64) {
1142        if let Some(start_frame_nanos) = self.start_frame_nanos {
1143            let elapsed_nanos = frame_nanos.saturating_sub(start_frame_nanos);
1144            let animation_nanos = ANIMATION_DURATION.as_nanos().min(u64::MAX as u128) as u64;
1145            if elapsed_nanos >= animation_nanos {
1146                self.start_frame_nanos = None;
1147            }
1148        }
1149    }
1150
1151    fn expand_fraction(&self) -> f32 {
1152        let raw = match self.start_frame_nanos {
1153            None => 1.0,
1154            Some(start_frame_nanos) => {
1155                let elapsed_nanos = current_frame_nanos().saturating_sub(start_frame_nanos);
1156                let animation_nanos = ANIMATION_DURATION.as_nanos().min(u64::MAX as u128) as u64;
1157                if elapsed_nanos >= animation_nanos {
1158                    1.0
1159                } else {
1160                    elapsed_nanos as f32 / animation_nanos as f32
1161                }
1162            }
1163        };
1164        let eased = animation::easing(raw);
1165        if self.expanded { eased } else { 1.0 - eased }
1166    }
1167
1168    fn is_animating(&self) -> bool {
1169        self.start_frame_nanos.is_some()
1170    }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    use tessera_ui::{
1176        CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
1177        PxPosition, remember, tessera,
1178    };
1179
1180    use crate::{
1181        column::column,
1182        lazy_list::lazy_column,
1183        modifier::{ModifierExt as _, SemanticsArgs},
1184        spacer::spacer,
1185        theme::{MaterialTheme, material_theme},
1186    };
1187
1188    use super::{navigation_rail, navigation_rail_item};
1189
1190    #[tessera]
1191    fn navigation_rail_row() {
1192        let selected = remember(|| 0usize);
1193        column().children(move || {
1194            navigation_rail().content(move |rail| {
1195                navigation_rail_item(rail)
1196                    .label("Home")
1197                    .selected(selected.get() == 0)
1198                    .on_click(move || selected.set(0));
1199                navigation_rail_item(rail)
1200                    .label("Search")
1201                    .selected(selected.get() == 1)
1202                    .on_click(move || selected.set(1));
1203                navigation_rail_item(rail)
1204                    .label("Settings")
1205                    .selected(selected.get() == 2)
1206                    .on_click(move || selected.set(2));
1207            });
1208            if selected.get() == 1 {
1209                spacer().modifier(Modifier::new().width(Dp(10.0)).height(Dp(10.0)).semantics(
1210                    SemanticsArgs {
1211                        test_tag: Some("rail-search-active".to_string()),
1212                        ..Default::default()
1213                    },
1214                ));
1215            }
1216        });
1217    }
1218
1219    #[tessera]
1220    fn navigation_rail_click_probe() {
1221        material_theme()
1222            .theme(MaterialTheme::default)
1223            .child(move || {
1224                lazy_column().item(move || {
1225                    navigation_rail_row();
1226                });
1227            });
1228    }
1229
1230    #[test]
1231    fn clicking_middle_item_selects_middle_item() {
1232        let mut session = tessera_ui::testing::start_layout_test_session(
1233            tessera_ui::testing::layout_test(|| {
1234                navigation_rail_click_probe();
1235            })
1236            .viewport_px(200, 400),
1237        );
1238        let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1239
1240        let now = tessera_ui::time::Instant::now();
1241        let cursor = Some(PxPosition::new(Px::new(48), Px::new(144)));
1242
1243        // Move the pointer over the middle item first so it rebuilds in
1244        // isolation inside `lazy_column`; the per-item selection must still
1245        // resolve to the middle destination after the click.
1246        let moved = PointerChange::new(
1247            now,
1248            1,
1249            CursorEventContent::Moved(PxPosition::new(Px::new(48), Px::new(144))),
1250            GestureState::TapCandidate,
1251        );
1252        let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1253            &mut session,
1254            16_666_667,
1255            vec![moved],
1256            cursor,
1257        );
1258
1259        let press = PointerChange::new(
1260            now,
1261            1,
1262            CursorEventContent::Pressed(PressKeyEventType::Left),
1263            GestureState::TapCandidate,
1264        );
1265        let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1266            &mut session,
1267            33_333_334,
1268            vec![press],
1269            cursor,
1270        );
1271        let release = PointerChange::new(
1272            now,
1273            1,
1274            CursorEventContent::Released(PressKeyEventType::Left),
1275            GestureState::TapCandidate,
1276        );
1277        let mut snapshot = tessera_ui::testing::pump_layout_test_session_with_input(
1278            &mut session,
1279            50_000_001,
1280            vec![release],
1281            cursor,
1282        );
1283        for frame in 3..40u64 {
1284            snapshot = tessera_ui::testing::pump_layout_test_session(
1285                &mut session,
1286                50_000_001 + 16_666_667 * (frame - 2),
1287            );
1288        }
1289
1290        snapshot.node("rail-search-active").exists();
1291    }
1292}
1293
1294#[cfg(all(test, not(target_family = "wasm")))]
1295mod modal_tests {
1296
1297    use tessera_ui::{
1298        HeadlessConfig, HeadlessRenderer, MOUSE_POINTER_ID, PressKeyEventType,
1299        accesskit::{Action, ActionRequest, Node, NodeId, TreeId},
1300        winit::{
1301            event::ElementState,
1302            keyboard::{KeyCode, PhysicalKey},
1303        },
1304    };
1305
1306    use crate::{button::button, text::text, theme::material_theme};
1307
1308    use super::*;
1309
1310    #[tessera]
1311    fn fixture(
1312        output: tessera_ui::testing::TestProbe<Option<(State<bool>, State<usize>, Callback)>>,
1313    ) {
1314        let open = remember(|| false);
1315        let selected = remember(|| 0usize);
1316        let close = Callback::new(move || open.set(false));
1317        output.set(Some((open, selected, close)));
1318        material_theme().child(move || {
1319            modal_navigation_rail()
1320                .is_open(open.get())
1321                .accessibility_label("Mail destinations")
1322                .on_close_request_shared(close)
1323                .main_content(move || {
1324                    column()
1325                        .modifier(Modifier::new().size(Dp(600.0), Dp(480.0)))
1326                        .children(move || {
1327                            button()
1328                                .accessibility_label("Open rail")
1329                                .on_click(move || open.set(true))
1330                                .child(|| {
1331                                    text("Open rail");
1332                                });
1333                        });
1334                })
1335                .header(move || {
1336                    button()
1337                        .accessibility_label("Close rail")
1338                        .on_click(move || open.set(false))
1339                        .child(|| {
1340                            text("Close rail");
1341                        });
1342                })
1343                .content(move |rail| {
1344                    for (index, label) in ["Inbox", "Saved"].into_iter().enumerate() {
1345                        navigation_rail_item(rail)
1346                            .label(label)
1347                            .selected(selected.get() == index)
1348                            .on_click(move || {
1349                                selected.set(index);
1350                                open.set(false);
1351                            });
1352                    }
1353                });
1354        });
1355    }
1356
1357    fn node(renderer: &HeadlessRenderer, role: Role, label: &str) -> (NodeId, Node) {
1358        renderer
1359            .accessibility_snapshot()
1360            .unwrap()
1361            .nodes
1362            .into_iter()
1363            .find(|(_, node)| {
1364                node.role() == role && node.label() == Some(label) && !node.is_hidden()
1365            })
1366            .unwrap_or_else(|| panic!("missing visible {role:?}: {label}"))
1367    }
1368
1369    fn action(renderer: &mut HeadlessRenderer, target: NodeId, action: Action) {
1370        assert!(renderer.dispatch_accessibility_action(ActionRequest {
1371            action,
1372            target_tree: TreeId::ROOT,
1373            target_node: target,
1374            data: None,
1375        }));
1376        renderer.render_frames(4);
1377    }
1378
1379    #[test]
1380    fn escape_adapter_dismisses_only_pressed_escape() {
1381        use tessera_ui::testing::{
1382            layout_test, pump_layout_test_session, start_layout_test_session,
1383        };
1384
1385        let output = tessera_ui::testing::TestProbe::default();
1386        let observed = output.clone();
1387        let mut session = start_layout_test_session(
1388            layout_test(move || {
1389                fixture(observed.clone());
1390            })
1391            .viewport_px(600, 480),
1392        );
1393        pump_layout_test_session(&mut session, 0);
1394        let open = output.get().expect("mounted modal rail").0;
1395        let close = output.get().expect("mounted modal rail").2;
1396        open.set(true);
1397        assert!(!modal_rail_key(
1398            PhysicalKey::Code(KeyCode::Enter),
1399            ElementState::Pressed,
1400            close
1401        ));
1402        assert!(open.get());
1403        assert!(!modal_rail_key(
1404            PhysicalKey::Code(KeyCode::Escape),
1405            ElementState::Released,
1406            close
1407        ));
1408        assert!(open.get());
1409        assert!(modal_rail_key(
1410            PhysicalKey::Code(KeyCode::Escape),
1411            ElementState::Pressed,
1412            close
1413        ));
1414        assert!(!open.get());
1415    }
1416
1417    #[test]
1418    #[ignore = "Requires a native GPU adapter for modal semantics, input and focus restoration"]
1419    fn modal_scrim_destination_and_escape_adapter_restore_origin_without_focus_activation() {
1420        crate::test_utils::run_isolated_test(
1421            "navigation_rail::modal_tests::modal_scrim_destination_and_escape_adapter_restore_origin_without_focus_activation",
1422            || {
1423                let output = tessera_ui::testing::TestProbe::default();
1424                let observed = output.clone();
1425                let mut renderer = HeadlessRenderer::new(
1426                    Box::new(move || {
1427                        fixture(observed.clone());
1428                    }),
1429                    vec![Box::new(crate::TesseraComponents)],
1430                    HeadlessConfig {
1431                        width: 600,
1432                        height: 480,
1433                        sample_count: 1,
1434                        ..Default::default()
1435                    },
1436                )
1437                .unwrap();
1438                renderer.render_frames(4);
1439                for dismiss in 0..3 {
1440                    let origin = node(&renderer, Role::Button, "Open rail").0;
1441                    action(&mut renderer, origin, Action::Focus);
1442                    action(&mut renderer, origin, Action::Click);
1443                    assert!(output.get().expect("mounted modal rail").0.get());
1444                    let snapshot = renderer.accessibility_snapshot().unwrap();
1445                    assert!(!snapshot.nodes.iter().any(|(id, _)| *id == origin));
1446                    let dialog = node(&renderer, Role::Dialog, "Mail destinations").1;
1447                    let bounds = dialog.bounds().unwrap();
1448                    assert_eq!(
1449                        (bounds.x0, bounds.y0, bounds.width(), bounds.height()),
1450                        (0.0, 0.0, 220.0, 480.0)
1451                    );
1452                    node(&renderer, Role::Button, "Close rail");
1453                    node(&renderer, Role::Tab, "Inbox");
1454                    let saved = node(&renderer, Role::Tab, "Saved").0;
1455                    let selected = output.get().expect("mounted modal rail").1.get();
1456                    action(&mut renderer, saved, Action::Focus);
1457                    assert_eq!(renderer.accessibility_snapshot().unwrap().focus, saved);
1458                    assert_eq!(output.get().expect("mounted modal rail").1.get(), selected);
1459                    assert!(output.get().expect("mounted modal rail").0.get());
1460                    match dismiss {
1461                        0 => {
1462                            let point = PxPosition::from_f32_xy(550.0, 240.0);
1463                            renderer.pointer_move(MOUSE_POINTER_ID, point);
1464                            renderer.pointer_press(
1465                                MOUSE_POINTER_ID,
1466                                point,
1467                                PressKeyEventType::Left,
1468                            );
1469                            renderer.pointer_release(
1470                                MOUSE_POINTER_ID,
1471                                point,
1472                                PressKeyEventType::Left,
1473                            );
1474                        }
1475                        1 => action(&mut renderer, saved, Action::Click),
1476                        _ => {
1477                            assert!(modal_rail_key(
1478                                PhysicalKey::Code(KeyCode::Escape),
1479                                ElementState::Pressed,
1480                                output.get().expect("mounted modal rail").2
1481                            ));
1482                        }
1483                    }
1484                    renderer.render_frames(6);
1485                    assert!(!output.get().expect("mounted modal rail").0.get());
1486                    assert_eq!(renderer.accessibility_snapshot().unwrap().focus, origin);
1487                    assert_eq!(
1488                        output.get().expect("mounted modal rail").1.get(),
1489                        if dismiss == 0 { 0 } else { 1 }
1490                    );
1491                }
1492            },
1493        );
1494    }
1495}