Skip to main content

tessera_components/
navigation_drawer.rs

1//! Navigation drawer surfaces and selectable destinations.
2//!
3//! ## Usage
4//!
5//! Organize primary destinations and account sections in spacious layouts.
6
7use tessera_ui::{
8    Callback, Dp, FocusRequester, Modifier, RenderSlot, accesskit::Role,
9    modifier::FocusModifierExt as _, provide_context, remember, tessera, use_context,
10};
11
12use crate::{
13    alignment::CrossAxisAlignment,
14    column::column,
15    modifier::{InteractionState, ModifierExt, Padding, SelectableArgs},
16    ripple_state::{RippleSpec, RippleState},
17    row::row,
18    shape_def::{RoundedCorner, Shape},
19    spacer::spacer,
20    surface::surface,
21    text::text,
22    theme::{MaterialTheme, TextStyle},
23};
24
25/// Presentation of a navigation drawer surface.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum NavigationDrawerStyle {
28    /// A surface displayed alongside page content.
29    #[default]
30    Standard,
31    /// A raised surface intended for a modal overlay.
32    Modal,
33}
34
35/// # navigation_drawer
36///
37/// Arrange primary destinations on a 360dp navigation surface.
38///
39/// ## Usage
40///
41/// Provide persistent navigation or drawer content inside a modal side sheet.
42///
43/// ## Parameters
44///
45/// - `modifier` — drawer placement and optional height constraints.
46/// - `style` — standard surface or raised modal surface treatment.
47/// - `content` — destinations, headings, and separators in display order.
48/// - `accessibility_label` — name announced for this navigation region.
49///
50/// ## Examples
51///
52/// ```
53/// use tessera_components::navigation_drawer::{navigation_drawer, navigation_drawer_item};
54/// use tessera_ui::{remember, tessera};
55///
56/// #[tessera]
57/// fn navigation() {
58///     let selected = remember(|| 0usize);
59///     navigation_drawer().content(move || {
60///         navigation_drawer_item()
61///             .label("Inbox")
62///             .selected(selected.get() == 0)
63///             .on_click(move || selected.set(0));
64///     });
65/// }
66/// ```
67#[tessera]
68pub fn navigation_drawer(
69    modifier: Option<Modifier>,
70    style: Option<NavigationDrawerStyle>,
71    content: Option<RenderSlot>,
72    #[prop(into)] accessibility_label: Option<String>,
73) {
74    let scheme = use_context::<MaterialTheme>()
75        .expect("MaterialTheme must be provided")
76        .get()
77        .color_scheme;
78    let modal = style.unwrap_or_default() == NavigationDrawerStyle::Modal;
79    let round = RoundedCorner::manual(Dp(16.0), 2.0);
80    let square = RoundedCorner::manual(Dp(0.0), 2.0);
81    surface()
82        .modifier(modifier.unwrap_or_default().width(Dp(360.0)).focus_group())
83        .style(
84            (if modal {
85                scheme.surface_container_low
86            } else {
87                scheme.surface
88            })
89            .into(),
90        )
91        .shape(Shape::RoundedRectangle {
92            top_left: square,
93            top_right: round,
94            bottom_right: round,
95            bottom_left: square,
96        })
97        .elevation(if modal { Dp(1.0) } else { Dp(0.0) })
98        .block_input(false)
99        .accessibility_role(Role::Navigation)
100        .accessibility_label_optional(accessibility_label)
101        .child(move || {
102            column()
103                .modifier(
104                    Modifier::new()
105                        .fill_max_width()
106                        .padding_symmetric(Dp(12.0), Dp(12.0)),
107                )
108                .children_shared(content.clone().unwrap_or_else(RenderSlot::empty));
109        });
110}
111
112/// # navigation_drawer_item
113///
114/// Show a selectable destination with an optional icon and trailing badge.
115///
116/// ## Usage
117///
118/// Switch between primary destinations while announcing the current selection.
119///
120/// ## Parameters
121///
122/// - `modifier` — additional layout and semantics.
123/// - `label` — destination name.
124/// - `selected` — whether this is the current destination.
125/// - `enabled` — whether pointer and keyboard activation are available.
126/// - `on_click` — destination activation callback.
127/// - `icon` — optional 24dp leading icon.
128/// - `badge` — optional trailing count or status.
129///
130/// ## Examples
131///
132/// ```
133/// use tessera_components::navigation_drawer::navigation_drawer_item;
134/// use tessera_ui::{remember, tessera};
135///
136/// #[tessera]
137/// fn destination() {
138///     let selected = remember(|| false);
139///     navigation_drawer_item()
140///         .label("Saved")
141///         .selected(selected.get())
142///         .on_click(move || selected.set(true));
143/// }
144/// ```
145#[tessera]
146pub fn navigation_drawer_item(
147    modifier: Option<Modifier>,
148    #[prop(into)] label: Option<String>,
149    selected: Option<bool>,
150    enabled: Option<bool>,
151    on_click: Option<Callback>,
152    icon: Option<RenderSlot>,
153    badge: Option<RenderSlot>,
154) {
155    let theme = use_context::<MaterialTheme>()
156        .expect("MaterialTheme must be provided")
157        .get();
158    let scheme = theme.color_scheme;
159    let selected = selected.unwrap_or(false);
160    let enabled = enabled.unwrap_or(true);
161    let label = label.unwrap_or_default();
162    let foreground = if selected {
163        scheme.on_secondary_container
164    } else {
165        scheme.on_surface_variant
166    };
167    let foreground = if enabled {
168        foreground
169    } else {
170        scheme.on_surface.with_alpha(0.38)
171    };
172    let interaction = remember(InteractionState::new);
173    let ripple = remember(RippleState::new);
174    let focus = remember(FocusRequester::new).get();
175    let modifier = modifier
176        .unwrap_or_default()
177        .fill_max_width()
178        .height(Dp(56.0))
179        .selectable_with(SelectableArgs {
180            selected,
181            enabled,
182            on_click: on_click.unwrap_or_default(),
183            role: Some(Role::Tab),
184            label: Some(label.clone()),
185            interaction_state: Some(interaction),
186            focus_requester: Some(focus),
187            on_press: Some(
188                (move |ctx: crate::modifier::PointerEventContext| {
189                    ripple.with_mut(|state| {
190                        state.start_animation_with_spec(
191                            ctx.normalized_pos,
192                            ctx.size,
193                            RippleSpec {
194                                bounded: true,
195                                radius: None,
196                            },
197                        )
198                    });
199                })
200                .into(),
201            ),
202            on_release: Some(
203                (move |_ctx| {
204                    ripple.with_mut(|state| state.release());
205                })
206                .into(),
207            ),
208            ..Default::default()
209        });
210    surface()
211        .modifier(modifier)
212        .style(
213            (if selected {
214                scheme.secondary_container
215            } else {
216                tessera_ui::Color::TRANSPARENT
217            })
218            .into(),
219        )
220        .content_color(foreground)
221        .shape(Shape::CAPSULE)
222        .enabled(enabled)
223        .block_input(false)
224        .interaction_state(interaction)
225        .focus_requester(focus)
226        .show_focus_indicator(true)
227        .ripple_state(ripple)
228        .ripple_color(foreground)
229        .child(move || {
230            let label = label.clone();
231            row()
232                .modifier(Modifier::new().fill_max_size().padding(Padding::new(
233                    Dp(16.0),
234                    Dp::ZERO,
235                    Dp(24.0),
236                    Dp::ZERO,
237                )))
238                .cross_axis_alignment(CrossAxisAlignment::Center)
239                .children(move || {
240                    if let Some(icon) = &icon {
241                        icon.render();
242                        spacer().modifier(Modifier::new().width(Dp(12.0)));
243                    }
244                    text(label.clone())
245                        .style(theme.typography.label_large)
246                        .color(foreground);
247                    spacer().modifier(Modifier::new().weight(1.0));
248                    if let Some(badge) = &badge {
249                        spacer().modifier(Modifier::new().width(Dp(12.0)));
250                        provide_context(
251                            || theme.typography.label_large,
252                            || {
253                                let style = use_context::<TextStyle>()
254                                    .expect("drawer badge style must be provided");
255                                if style.get() != theme.typography.label_large {
256                                    style.set(theme.typography.label_large);
257                                }
258                                badge.render();
259                            },
260                        );
261                    }
262                });
263        });
264}
265
266#[cfg(test)]
267mod tests {
268
269    use tessera_ui::{
270        CursorEventContent, GestureState, PointerChange, PressKeyEventType, PxPosition, State,
271        testing::{
272            layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
273            start_layout_test_session,
274        },
275    };
276
277    use crate::{modifier::SemanticsArgs, theme::material_theme};
278
279    use super::*;
280
281    #[tessera]
282    fn fixture(output: tessera_ui::testing::TestProbe<Option<State<usize>>>) {
283        let selected = remember(|| 0usize);
284        output.set(Some(selected));
285        material_theme().child(move || {
286            column().children(move || {
287                navigation_drawer()
288                    .modifier(Modifier::new().semantics(SemanticsArgs {
289                        test_tag: Some("drawer".into()),
290                        ..Default::default()
291                    }))
292                    .content(move || {
293                        navigation_drawer_item()
294                            .label("Inbox")
295                            .selected(selected.get() == 0)
296                            .on_click(move || selected.set(0));
297                        navigation_drawer_item()
298                            .label("Saved")
299                            .badge(|| {
300                                text("24").modifier(Modifier::new().semantics(SemanticsArgs {
301                                    test_tag: Some("saved-badge".into()),
302                                    ..Default::default()
303                                }));
304                            })
305                            .selected(selected.get() == 1)
306                            .modifier(Modifier::new().semantics(SemanticsArgs {
307                                test_tag: Some("saved".into()),
308                                ..Default::default()
309                            }))
310                            .on_click(move || selected.set(1));
311                        navigation_drawer_item()
312                            .label("Disabled")
313                            .enabled(false)
314                            .modifier(Modifier::new().semantics(SemanticsArgs {
315                                test_tag: Some("disabled".into()),
316                                ..Default::default()
317                            }))
318                            .on_click(move || selected.set(2));
319                    });
320            });
321        });
322    }
323
324    #[test]
325    fn destinations_keep_token_bounds_select_and_ignore_disabled_input() {
326        let output = tessera_ui::testing::TestProbe::default();
327        let probe = output.clone();
328        let mut session = start_layout_test_session(
329            layout_test(move || {
330                fixture(probe.clone());
331            })
332            .viewport_px(400, 400),
333        );
334        let snapshot = pump_layout_test_session(&mut session, 0);
335        snapshot.node("saved").size(336, 56);
336        let saved = snapshot.node("saved").bounds();
337        let badge = snapshot.node("saved-badge").bounds();
338        let drawer = snapshot.node("drawer").bounds();
339        assert_eq!(
340            (saved.x + saved.width - badge.x - badge.width).to_f32(),
341            24.0
342        );
343        assert_eq!(
344            (drawer.x + drawer.width - badge.x - badge.width).to_f32(),
345            36.0
346        );
347        let disabled = snapshot.node("disabled").bounds();
348        for (index, bounds) in [saved, disabled].into_iter().enumerate() {
349            let cursor = Some(PxPosition::from_f32_xy(
350                bounds.x.to_f32() + 100.0,
351                bounds.y.to_f32() + 28.0,
352            ));
353            for (event, content) in [
354                CursorEventContent::Pressed(PressKeyEventType::Left),
355                CursorEventContent::Released(PressKeyEventType::Left),
356            ]
357            .into_iter()
358            .enumerate()
359            {
360                pump_layout_test_session_with_input(
361                    &mut session,
362                    (index * 2 + event + 1) as u64 * 16_666_667,
363                    vec![PointerChange::new(
364                        tessera_ui::current_frame_time().unwrap(),
365                        1,
366                        content,
367                        GestureState::TapCandidate,
368                    )],
369                    cursor,
370                );
371            }
372            pump_layout_test_session(&mut session, (index as u64 + 1) * 100_000_000);
373            assert_eq!(output.get().unwrap().get(), 1);
374        }
375    }
376
377    #[tessera]
378    fn shape_fixture() {
379        material_theme()
380            .theme(|| {
381                let mut theme = MaterialTheme::default();
382                theme.color_scheme.surface = tessera_ui::Color::BLACK;
383                theme
384            })
385            .child(|| {
386                column()
387                    .modifier(
388                        Modifier::new()
389                            .fill_max_size()
390                            .background(tessera_ui::Color::WHITE),
391                    )
392                    .children(|| {
393                        navigation_drawer().modifier(Modifier::new().height(Dp(100.0)));
394                    });
395            });
396    }
397
398    #[test]
399    #[ignore = "Requires a native GPU adapter to verify navigation drawer corner pixels"]
400    fn standard_drawer_rounds_only_the_ending_corners() {
401        let mut renderer = tessera_ui::HeadlessRenderer::new(
402            Box::new(shape_fixture),
403            vec![Box::new(crate::TesseraComponents)],
404            tessera_ui::HeadlessConfig {
405                width: 400,
406                height: 120,
407                sample_count: 1,
408                ..Default::default()
409            },
410        )
411        .unwrap();
412        renderer.render_frames(3);
413        let pixels = renderer.read_pixels().unwrap();
414        let pixel = |x: usize, y: usize| -> [u8; 4] {
415            let offset = (y * 400 + x) * 4;
416            pixels[offset..offset + 4].try_into().unwrap()
417        };
418        for (x, y) in [(0, 0), (0, 99), (343, 0), (343, 99), (359, 16), (359, 83)] {
419            assert_eq!(pixel(x, y), [0, 0, 0, 255]);
420        }
421        for (x, y) in [(359, 0), (359, 99), (356, 2), (356, 97)] {
422            assert_eq!(pixel(x, y), [255; 4]);
423        }
424    }
425}