1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum NavigationDrawerStyle {
28 #[default]
30 Standard,
31 Modal,
33}
34
35#[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#[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}