Skip to main content

tessera_components/
navigation_bar.rs

1//! Material Design 3 navigation bar for primary app destinations.
2//!
3//! ## Usage
4//!
5//! Use for bottom navigation between a small set of top-level destinations.
6use tessera_foundation::gesture::TapRecognizer;
7use tessera_ui::{
8    AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusRequester,
9    FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition, PxSize,
10    RenderSlot, State,
11    accesskit::Role,
12    current_frame_nanos,
13    layout::{MeasureScope, layout},
14    modifier::FocusModifierExt as _,
15    provide_context, receive_frame_nanos, remember, tessera, use_context,
16};
17
18use crate::{
19    animation::SelectionAnimation,
20    modifier::{InteractionState, ModifierExt, PointerEventContext, SelectableArgs},
21    ripple_state::{RippleSpec, RippleState},
22    shape_def::Shape,
23    surface::{SurfaceStyle, surface},
24    text::text,
25    theme::{ContentColor, MaterialTheme},
26};
27
28const CONTAINER_HEIGHT: Dp = Dp(64.0);
29const INDICATOR_WIDTH: Dp = Dp(56.0);
30const INDICATOR_HEIGHT: Dp = Dp(32.0);
31const INDICATOR_TO_LABEL_PADDING: Dp = Dp(4.0);
32const INDICATOR_VERTICAL_PADDING: Dp = Dp(4.0);
33
34pub(crate) fn interpolate_color(from: Color, to: Color, progress: f32) -> Color {
35    Color {
36        r: from.r + (to.r - from.r) * progress,
37        g: from.g + (to.g - from.g) * progress,
38        b: from.b + (to.b - from.b) * progress,
39        a: from.a + (to.a - from.a) * progress,
40    }
41}
42
43/// Position of the icon relative to the destination label.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub enum NavigationBarIconPosition {
46    /// Switch from top to start at a container width of 600dp.
47    #[default]
48    Auto,
49    /// Place the icon above the label on compact screens.
50    Top,
51    /// Place the icon before the label on wider screens.
52    Start,
53}
54
55/// Controls label visibility for a navigation bar item.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum NavigationBarLabelBehavior {
58    /// Always render the label.
59    #[default]
60    AlwaysShow,
61    /// Fade the label in only when the item is selected.
62    SelectedOnly,
63}
64
65#[tessera]
66fn navigation_bar_item_view_content(
67    item: Option<NavigationBarItemDefinition>,
68    selection_fraction: Option<f32>,
69    interaction_state: Option<State<InteractionState>>,
70    ripple_state: Option<State<RippleState>>,
71    focus_requester: Option<FocusRequester>,
72) {
73    let item = item.unwrap_or_default();
74    let selection_fraction = selection_fraction.unwrap_or(0.0);
75    let interaction_state = interaction_state.expect("interaction_state must be set");
76    let ripple_state = ripple_state.expect("ripple_state must be set");
77    let focus_requester = focus_requester.expect("focus_requester must be set");
78    let theme = use_context::<MaterialTheme>()
79        .expect("MaterialTheme must be provided")
80        .get();
81    let scheme = theme.color_scheme;
82    let typography = theme.typography;
83
84    let always_show_label = matches!(item.label_behavior, NavigationBarLabelBehavior::AlwaysShow);
85    let has_label = !item.label.is_empty();
86    let has_icon = item.icon.is_some();
87
88    let indicator_alpha = selection_fraction;
89    let icon_color = interpolate_color(
90        scheme.on_surface_variant,
91        scheme.on_secondary_container,
92        selection_fraction,
93    );
94    let ripple_color = icon_color;
95
96    let label_alpha = if always_show_label {
97        1.0
98    } else {
99        selection_fraction
100    };
101    let label_color_base = interpolate_color(
102        scheme.on_surface_variant,
103        if item.icon_position == NavigationBarIconPosition::Start {
104            scheme.on_secondary_container
105        } else {
106            scheme.secondary
107        },
108        selection_fraction,
109    );
110    let label_color = label_color_base.with_alpha(label_color_base.a * label_alpha);
111
112    let indicator_color = scheme.secondary_container.with_alpha(indicator_alpha);
113
114    layout()
115        .policy(NavigationBarItemLayout {
116            selection_fraction,
117            always_show_label,
118            has_label,
119            has_icon,
120            icon_position: item.icon_position,
121        })
122        .child(move || {
123            surface()
124                .style(SurfaceStyle::Filled {
125                    color: indicator_color,
126                })
127                .shape(Shape::CAPSULE)
128                .show_state_layer(false)
129                .show_ripple(false)
130                .child(|| {});
131
132            surface()
133                .style(SurfaceStyle::Filled {
134                    color: Color::TRANSPARENT,
135                })
136                .shape(Shape::CAPSULE)
137                .enabled(true)
138                .interaction_state(interaction_state)
139                .focus_requester(focus_requester)
140                .show_focus_indicator(true)
141                .ripple_color(ripple_color)
142                .child(move || {
143                    surface()
144                        .style(SurfaceStyle::Filled {
145                            color: Color::TRANSPARENT,
146                        })
147                        .shape(Shape::CAPSULE)
148                        .modifier(Modifier::new().fill_max_size())
149                        .enabled(true)
150                        .ripple_color(ripple_color)
151                        .show_state_layer(false)
152                        .ripple_state(ripple_state)
153                        .child(|| {});
154                });
155
156            if let Some(draw_icon) = item.icon {
157                provide_context(
158                    || ContentColor {
159                        current: icon_color,
160                    },
161                    || {
162                        let color = use_context::<ContentColor>().unwrap();
163                        if color.with(|value| value.current) != icon_color {
164                            color.set(ContentColor {
165                                current: icon_color,
166                            });
167                        }
168                        draw_icon.render();
169                    },
170                );
171            }
172
173            if has_label {
174                let label = item.label.clone();
175                provide_context(
176                    || typography.label_medium,
177                    move || {
178                        text(label.clone())
179                            .color(label_color)
180                            .style(typography.label_medium);
181                    },
182                );
183            }
184        });
185}
186
187#[derive(Clone, PartialEq)]
188struct NavigationBarItemLayout {
189    selection_fraction: f32,
190    always_show_label: bool,
191    has_label: bool,
192    has_icon: bool,
193    icon_position: NavigationBarIconPosition,
194}
195
196impl tessera_ui::LayoutModifierNode for NavigationBarItemLayout {
197    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
198        let mut result = LayoutResult::default();
199        let children = input.children();
200        let parent_width = input
201            .parent_constraint()
202            .width()
203            .resolve_max()
204            .unwrap_or(Px::ZERO);
205
206        let min_height = CONTAINER_HEIGHT.to_px();
207
208        let indicator_background = children[0];
209        let indicator_ripple = children[1];
210        let mut child_index = 2;
211
212        let icon_id = if self.has_icon {
213            let id = children[child_index];
214            child_index += 1;
215            Some(id)
216        } else {
217            None
218        };
219
220        let label_id = if self.has_label {
221            let id = children[child_index];
222            Some(id)
223        } else {
224            None
225        };
226
227        let child_constraint = Constraint::NONE;
228
229        let icon_size = if let Some(icon_id) = icon_id {
230            Some(icon_id.measure(input, &child_constraint)?)
231        } else {
232            None
233        };
234
235        let horizontal = self.icon_position == NavigationBarIconPosition::Start && self.has_label;
236        let label_size = if let Some(label_id) = label_id {
237            let reserved = if horizontal {
238                icon_size.map_or(Px::ZERO, |size| size.width) + Dp(36.0).to_px()
239            } else {
240                Dp(8.0).to_px()
241            };
242            Some(label_id.measure(
243                input,
244                &Constraint::new(
245                    AxisConstraint::new(Px::ZERO, Some((parent_width - reserved).max(Px::ZERO))),
246                    AxisConstraint::NONE,
247                ),
248            )?)
249        } else {
250            None
251        };
252
253        let width = parent_width;
254        let icon_height = icon_size.map_or(Px::ZERO, |size| size.height);
255        let label_height = label_size.map_or(Px::ZERO, |size| size.height);
256        let natural_height = if horizontal {
257            icon_height.max(label_height) + Dp(40.0).to_px()
258        } else if self.has_label {
259            icon_height
260                + INDICATOR_VERTICAL_PADDING.to_px() * 2
261                + INDICATOR_TO_LABEL_PADDING.to_px()
262                + label_height
263                + Dp(12.0).to_px()
264        } else {
265            INDICATOR_HEIGHT.to_px() + Dp(12.0).to_px()
266        };
267        let height = input
268            .parent_constraint()
269            .height()
270            .clamp(min_height.max(natural_height));
271        let indicator_width = if horizontal {
272            icon_size.map_or(Px::ZERO, |size| size.width)
273                + label_size.map_or(Px::ZERO, |size| size.width)
274                + Dp(36.0).to_px()
275        } else {
276            INDICATOR_WIDTH.to_px()
277        };
278        let indicator_height = if horizontal {
279            Dp(40.0).to_px().max(label_height + Dp(16.0).to_px())
280        } else {
281            INDICATOR_HEIGHT.to_px()
282        };
283        let animated_width =
284            Px((indicator_width.to_f32() * self.selection_fraction).round() as i32);
285        let indicator_size = indicator_background.measure(
286            input,
287            &Constraint::new(
288                AxisConstraint::exact(animated_width),
289                AxisConstraint::exact(indicator_height),
290            ),
291        )?;
292        let indicator_ripple_size = indicator_ripple.measure(
293            input,
294            &Constraint::new(
295                AxisConstraint::exact(indicator_width),
296                AxisConstraint::exact(indicator_height),
297            ),
298        )?;
299        if horizontal {
300            let x = (width - indicator_width) / 2;
301            let y = (height - indicator_height) / 2;
302            result.place_child(
303                indicator_background,
304                PxPosition::new((width - animated_width) / 2, y),
305            );
306            result.place_child(indicator_ripple, PxPosition::new(x, y));
307            let icon_width = icon_size.map_or(Px::ZERO, |size| size.width);
308            if let (Some(id), Some(size)) = (icon_id, icon_size) {
309                result.place_child(
310                    id,
311                    PxPosition::new(x + Dp(16.0).to_px(), (height - size.height) / 2),
312                );
313            }
314            if let (Some(id), Some(size)) = (label_id, label_size) {
315                result.place_child(
316                    id,
317                    PxPosition::new(
318                        x + Dp(20.0).to_px() + icon_width,
319                        (height - size.height) / 2,
320                    ),
321                );
322            }
323            return Ok(result.with_size(ComputedData { width, height }));
324        }
325
326        if !self.has_label {
327            let ripple_x = (width - indicator_ripple_size.width) / 2;
328            let ripple_y = (height - indicator_ripple_size.height) / 2;
329            let indicator_x = (width - indicator_size.width) / 2;
330            let indicator_y = (height - indicator_size.height) / 2;
331            result.place_child(
332                indicator_background,
333                PxPosition::new(indicator_x, indicator_y),
334            );
335            result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
336
337            if let (Some(icon_id), Some(icon_size)) = (icon_id, icon_size) {
338                let icon_x = (width - icon_size.width) / 2;
339                let icon_y = (height - icon_size.height) / 2;
340                result.place_child(icon_id, PxPosition::new(icon_x, icon_y));
341            }
342
343            return Ok(result.with_size(ComputedData { width, height }));
344        }
345
346        let icon_size = icon_size.map(|size| size.size()).unwrap_or(ComputedData {
347            width: Px::ZERO,
348            height: Px::ZERO,
349        });
350        let label_size = label_size.map(|size| size.size()).unwrap_or(ComputedData {
351            width: Px::ZERO,
352            height: Px::ZERO,
353        });
354
355        let indicator_vertical_padding_px = INDICATOR_VERTICAL_PADDING.to_px();
356        let content_height = icon_size.height
357            + indicator_vertical_padding_px * 2
358            + INDICATOR_TO_LABEL_PADDING.to_px()
359            + label_size.height;
360
361        let content_vertical_padding =
362            ((height - content_height) / 2).max(indicator_vertical_padding_px);
363        let selected_icon_y = content_vertical_padding + indicator_vertical_padding_px;
364        let unselected_icon_y = if self.always_show_label {
365            selected_icon_y
366        } else {
367            (height - icon_size.height) / 2
368        };
369
370        let icon_distance = unselected_icon_y - selected_icon_y;
371        let offset =
372            Px(((icon_distance.0 as f32) * (1.0 - self.selection_fraction)).round() as i32);
373
374        let icon_x = (width - icon_size.width) / 2;
375        let label_x = (width - label_size.width) / 2;
376        let ripple_x = (width - indicator_ripple_size.width) / 2;
377        let indicator_x = (width - indicator_size.width) / 2;
378
379        let ripple_y = selected_icon_y - indicator_vertical_padding_px;
380        let indicator_y = selected_icon_y - indicator_vertical_padding_px;
381        let icon_y = selected_icon_y;
382        let label_y = selected_icon_y
383            + icon_size.height
384            + indicator_vertical_padding_px
385            + INDICATOR_TO_LABEL_PADDING.to_px();
386
387        result.place_child(
388            indicator_background,
389            PxPosition::new(indicator_x, Px(indicator_y.0 + offset.0)),
390        );
391        result.place_child(
392            indicator_ripple,
393            PxPosition::new(ripple_x, Px(ripple_y.0 + offset.0)),
394        );
395
396        if let Some(icon_id) = icon_id {
397            result.place_child(icon_id, PxPosition::new(icon_x, Px(icon_y.0 + offset.0)));
398        }
399
400        if (self.always_show_label || self.selection_fraction != 0.0)
401            && let Some(label_id) = label_id
402        {
403            result.place_child(label_id, PxPosition::new(label_x, Px(label_y.0 + offset.0)));
404        }
405
406        Ok(result.with_size(ComputedData { width, height }))
407    }
408}
409
410#[tessera]
411fn navigation_bar_item_view(
412    item: Option<NavigationBarItemDefinition>,
413    selected: Option<bool>,
414    selection_fraction: Option<f32>,
415    on_click: Option<Callback>,
416) {
417    let item = item.unwrap_or_default();
418    let selected = selected.unwrap_or(false);
419    let selection_fraction = selection_fraction.unwrap_or(0.0);
420    let on_click = on_click.unwrap_or_default();
421    let interaction_state = remember(InteractionState::new);
422    let ripple_state = remember(RippleState::new);
423    let tap_recognizer = remember(TapRecognizer::default);
424    let focus = remember(FocusRequester::new).get();
425
426    let label = item.label.clone();
427
428    let on_press = move |ctx: PointerEventContext| {
429        let spec = RippleSpec {
430            bounded: true,
431            radius: None,
432        };
433        ripple_state.with_mut(|state| {
434            state.start_animation_with_spec(
435                ctx.normalized_pos,
436                PxSize::new(INDICATOR_WIDTH.to_px(), INDICATOR_HEIGHT.to_px()),
437                spec,
438            );
439        });
440    };
441    let on_release = move |_ctx: PointerEventContext| {
442        ripple_state.with_mut(|state| state.release());
443    };
444
445    let selectable_args = SelectableArgs {
446        selected,
447        on_click,
448        enabled: true,
449        role: Some(Role::Tab),
450        label: Some(label),
451        description: item.accessibility_description.clone(),
452        interaction_state: Some(interaction_state),
453        focus_requester: Some(focus),
454        on_press: Some(on_press.into()),
455        on_release: Some(on_release.into()),
456        tap_recognizer: Some(tap_recognizer),
457        ..Default::default()
458    };
459
460    let modifier = Modifier::new().selectable_with(selectable_args);
461    layout().modifier(modifier).child({
462        let item = item.clone();
463        move || {
464            navigation_bar_item_view_content()
465                .item(item.clone())
466                .selection_fraction(selection_fraction)
467                .interaction_state(interaction_state)
468                .ripple_state(ripple_state)
469                .focus_requester(focus);
470        }
471    });
472}
473
474#[derive(Clone, PartialEq, Default)]
475struct NavigationBarItemDefinition {
476    label: String,
477    icon: Option<RenderSlot>,
478    accessibility_description: Option<String>,
479    label_behavior: NavigationBarLabelBehavior,
480    icon_position: NavigationBarIconPosition,
481}
482
483/// # navigation_bar_item
484///
485/// Renders a single destination inside [`navigation_bar`].
486///
487/// ## Usage
488///
489/// Declare one primary destination in a navigation bar content slot.
490///
491/// ## Parameters
492///
493/// - `label` — text label shown under the icon.
494/// - `icon` — optional icon rendered above the label.
495/// - `selected` — whether this item is the active destination.
496/// - `icon_position` — place the icon above or before the label.
497/// - `on_click` — callback invoked after the item becomes selected.
498/// - `accessibility_description` — additional announcement such as an unread
499///   count.
500/// - `label_behavior` — whether the label is always shown or only when
501///   selected.
502///
503/// ## Examples
504///
505/// ```
506/// use tessera_components::navigation_bar::{navigation_bar, navigation_bar_item};
507/// use tessera_ui::{remember, tessera};
508///
509/// #[tessera]
510/// fn demo() {
511///     let selected = remember(|| 0usize);
512///     navigation_bar().content(move |position| {
513///         navigation_bar_item().icon_position(position)
514///             .label("Home")
515///             .selected(selected.get() == 0)
516///             .on_click(move || selected.set(0));
517///     });
518/// }
519/// ```
520#[tessera]
521pub fn navigation_bar_item(
522    #[prop(into)] label: Option<String>,
523    icon: Option<RenderSlot>,
524    on_click: Option<Callback>,
525    label_behavior: Option<NavigationBarLabelBehavior>,
526    selected: Option<bool>,
527    icon_position: Option<NavigationBarIconPosition>,
528    #[prop(into)] accessibility_description: Option<String>,
529) {
530    let icon_position = icon_position
531        .filter(|position| *position != NavigationBarIconPosition::Auto)
532        .unwrap_or(NavigationBarIconPosition::Top);
533    let label = label.unwrap_or_default();
534    let on_click = on_click.unwrap_or_default();
535    let label_behavior = label_behavior.unwrap_or_default();
536    let selected = selected.unwrap_or(false);
537
538    // Each item owns its own selection animation. State is keyed by the item's
539    // call position, so it stays stable across rebuilds without any shared
540    // index registry.
541    let animation = remember(|| SelectionAnimation::new(selected));
542    let frame_nanos = current_frame_nanos();
543    animation.with_mut(|state| state.set_selected(selected));
544    let selection_fraction = animation.with_mut(|state| {
545        state.update(frame_nanos);
546        state.progress()
547    });
548    if animation.with(|state| state.is_animating()) {
549        receive_frame_nanos(move |frame_nanos| {
550            let is_animating = animation.with_mut(|state| {
551                state.update(frame_nanos);
552                state.is_animating()
553            });
554            if is_animating {
555                tessera_ui::FrameNanosControl::Continue
556            } else {
557                tessera_ui::FrameNanosControl::Stop
558            }
559        });
560    }
561
562    layout()
563        .modifier(Modifier::new().weight(1.0))
564        .child(move || {
565            let item = NavigationBarItemDefinition {
566                label: label.clone(),
567                icon,
568                accessibility_description: accessibility_description.clone(),
569                label_behavior,
570                icon_position,
571            };
572            navigation_bar_item_view()
573                .item(item)
574                .selected(selected)
575                .selection_fraction(selection_fraction)
576                .on_click_shared(on_click);
577        });
578}
579
580/// # navigation_bar
581///
582/// Material navigation bar with active indicator and icon/label pairs.
583///
584/// ## Usage
585///
586/// Place at the bottom of the app to switch between 3–5 primary destinations.
587/// ## Parameters
588///
589/// - `content` — item declarations rendered inside the bar.
590/// - `icon_position` — icon placement; defaults to adapting at 600dp.
591///
592/// ## Examples
593///
594/// ```
595/// use tessera_components::navigation_bar::{navigation_bar, navigation_bar_item};
596/// use tessera_ui::{remember, tessera};
597///
598/// #[tessera]
599/// fn demo() {
600///     let selected = remember(|| 0usize);
601///     navigation_bar().content(move |position| {
602///         navigation_bar_item().icon_position(position)
603///             .label("Home")
604///             .selected(selected.get() == 0)
605///             .on_click(move || selected.set(0));
606///         navigation_bar_item().icon_position(position)
607///             .label("Search")
608///             .selected(selected.get() == 1)
609///             .on_click(move || selected.set(1));
610///     });
611/// }
612/// ```
613#[tessera]
614pub fn navigation_bar(
615    content: Option<tessera_ui::RenderSlotWith<NavigationBarIconPosition>>,
616    icon_position: Option<NavigationBarIconPosition>,
617) {
618    let icon_position = icon_position.unwrap_or_default();
619    let wide = remember(|| false);
620    let resolved_position = if icon_position == NavigationBarIconPosition::Auto {
621        if wide.get() {
622            NavigationBarIconPosition::Start
623        } else {
624            NavigationBarIconPosition::Top
625        }
626    } else {
627        icon_position
628    };
629    let scheme = use_context::<MaterialTheme>()
630        .expect("MaterialTheme must be provided")
631        .get()
632        .color_scheme;
633
634    let modifier = Modifier::new()
635        .on_layout_coordinates(move |coordinates: tessera_ui::LayoutCoordinates| {
636            let value = coordinates.local_size.width.to_f32() >= Dp(600.0).to_pixels_f32();
637            if value != wide.get() {
638                wide.set(value);
639            }
640        })
641        .focus_group()
642        .focus_traversal_policy(FocusTraversalPolicy::horizontal().wrap(true));
643    layout().modifier(modifier).child({
644        move || {
645            surface()
646                .modifier(
647                    Modifier::new()
648                        .fill_max_width()
649                        .default_min_size(Dp::ZERO, CONTAINER_HEIGHT),
650                )
651                .style(scheme.surface_container.into())
652                .block_input(true)
653                .child(move || {
654                    layout()
655                        .policy(NavigationBarLayout {
656                            centered: resolved_position == NavigationBarIconPosition::Start,
657                        })
658                        .child(move || {
659                            if let Some(content) = content {
660                                content.render(resolved_position);
661                            }
662                        });
663                });
664        }
665    });
666}
667
668#[derive(Clone, PartialEq)]
669struct NavigationBarLayout {
670    centered: bool,
671}
672
673impl tessera_ui::LayoutModifierNode for NavigationBarLayout {
674    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
675        let children = input.children();
676        let width = input
677            .parent_constraint()
678            .width()
679            .resolve_max()
680            .unwrap_or(Px::ZERO);
681        let minimum_height = CONTAINER_HEIGHT.to_px();
682        let mut height = input.parent_constraint().height().clamp(minimum_height);
683        let count = children.len();
684        let padding = if self.centered && count <= 6 {
685            Px((width.to_f32() * ((100.0 - 10.0 * (count as f32 + 3.0)) / 200.0)).round() as i32)
686        } else {
687            Px::ZERO
688        };
689        let available = width - padding * 2;
690        let mut result = LayoutResult::default();
691        let mut measured = Vec::with_capacity(count);
692        let maximum_height = input.parent_constraint().height().resolve_max();
693        for (index, child) in children.into_iter().enumerate() {
694            let start = Px(available.0 * index as i32 / count as i32);
695            let end = Px(available.0 * (index + 1) as i32 / count as i32);
696            let size = child.measure(
697                input,
698                &Constraint::new(
699                    AxisConstraint::exact(end - start),
700                    AxisConstraint::new(minimum_height, maximum_height),
701                ),
702            )?;
703            height = height.max(size.height);
704            measured.push((child, start, end, size.height));
705        }
706        for (child, start, end, child_height) in measured {
707            if child_height != height {
708                child.measure(
709                    input,
710                    &Constraint::new(
711                        AxisConstraint::exact(end - start),
712                        AxisConstraint::exact(height),
713                    ),
714                )?;
715            }
716            result.place_child(child, PxPosition::new(padding + start, Px::ZERO));
717        }
718        Ok(result.with_size(ComputedData { width, height }))
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use tessera_ui::{
725        CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
726        PxPosition, remember, tessera,
727    };
728
729    use crate::{
730        column::column,
731        lazy_list::lazy_column,
732        modifier::{ModifierExt as _, SemanticsArgs},
733        spacer::spacer,
734        theme::{MaterialTheme, material_theme},
735    };
736
737    use super::{navigation_bar, navigation_bar_item};
738
739    #[tessera]
740    fn navigation_bar_row() {
741        let selected = remember(|| 0usize);
742        column().children(move || {
743            navigation_bar().content(move |position| {
744                navigation_bar_item()
745                    .icon_position(position)
746                    .label("Home")
747                    .selected(selected.get() == 0)
748                    .on_click(move || selected.set(0));
749                navigation_bar_item()
750                    .icon_position(position)
751                    .label("Search")
752                    .selected(selected.get() == 1)
753                    .on_click(move || selected.set(1));
754                navigation_bar_item()
755                    .icon_position(position)
756                    .label("Settings")
757                    .selected(selected.get() == 2)
758                    .on_click(move || selected.set(2));
759            });
760            if selected.get() == 1 {
761                spacer().modifier(Modifier::new().width(Dp(10.0)).height(Dp(10.0)).semantics(
762                    SemanticsArgs {
763                        test_tag: Some("nav-search-active".to_string()),
764                        ..Default::default()
765                    },
766                ));
767            }
768        });
769    }
770
771    #[tessera]
772    fn navigation_bar_click_probe() {
773        material_theme()
774            .theme(MaterialTheme::default)
775            .child(move || {
776                lazy_column().item(move || {
777                    navigation_bar_row();
778                });
779            });
780    }
781
782    #[test]
783    fn clicking_middle_item_selects_middle_item() {
784        let mut session = tessera_ui::testing::start_layout_test_session(
785            tessera_ui::testing::layout_test(|| {
786                navigation_bar_click_probe();
787            })
788            .viewport_px(300, 200),
789        );
790        let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
791
792        // Three equal items span the 300px viewport, so the middle item covers
793        // x in [100, 200] and its center is at x = 150.
794        let now = tessera_ui::time::Instant::now();
795        let cursor = Some(PxPosition::new(Px::new(150), Px::new(40)));
796
797        // Move the pointer over the middle item first so it rebuilds in
798        // isolation inside `lazy_column`; the per-item selection must still
799        // resolve to the middle destination after the click.
800        let moved = PointerChange::new(
801            now,
802            1,
803            CursorEventContent::Moved(PxPosition::new(Px::new(150), Px::new(40))),
804            GestureState::TapCandidate,
805        );
806        let _ = tessera_ui::testing::pump_layout_test_session_with_input(
807            &mut session,
808            16_666_667,
809            vec![moved],
810            cursor,
811        );
812
813        let press = PointerChange::new(
814            now,
815            1,
816            CursorEventContent::Pressed(PressKeyEventType::Left),
817            GestureState::TapCandidate,
818        );
819        let _ = tessera_ui::testing::pump_layout_test_session_with_input(
820            &mut session,
821            33_333_334,
822            vec![press],
823            cursor,
824        );
825        let release = PointerChange::new(
826            now,
827            1,
828            CursorEventContent::Released(PressKeyEventType::Left),
829            GestureState::TapCandidate,
830        );
831        let mut snapshot = tessera_ui::testing::pump_layout_test_session_with_input(
832            &mut session,
833            50_000_001,
834            vec![release],
835            cursor,
836        );
837        for frame in 3..40u64 {
838            snapshot = tessera_ui::testing::pump_layout_test_session(
839                &mut session,
840                50_000_001 + 16_666_667 * (frame - 2),
841            );
842        }
843
844        snapshot.node("nav-search-active").exists();
845    }
846}