Skip to main content

tessera_components/
list_item.rs

1//! Material Design list item component.
2//!
3//! ## Usage
4//!
5//! Present rows of content in settings, inboxes, or selection lists.
6
7use tessera_ui::{
8    Callback, CallbackWith, Color, Dp, Modifier, RenderSlot, State, current_frame_nanos,
9    layout::layout, modifier::ModifierCapabilityExt as _, provide_context, receive_frame_nanos,
10    remember, tessera, use_context,
11};
12
13use crate::{
14    alignment::Alignment,
15    animation::shape_motion::ShapeMotion,
16    boxed::boxed,
17    icon::IconSize,
18    modifier::{InteractionState, ModifierExt as _, Padding},
19    shape_def::{RoundedCorner, Shape},
20    spacer::spacer,
21    surface::{SurfaceStyle, surface},
22    text::text,
23    theme::{ContentColor, MaterialAlpha, MaterialColorScheme, MaterialTheme, TextStyle},
24};
25
26use item_layout::ListItemLayout;
27use selection::{ListItemActivation, ListItemSelectionSemantics};
28
29pub use selection::ListItemSelectionMode;
30pub use shapes::{ListItemPosition, ListItemShapes};
31
32mod color_motion;
33mod item_layout;
34mod selection;
35mod shapes;
36
37#[cfg(test)]
38mod tests;
39
40#[cfg(all(test, not(target_family = "wasm")))]
41mod color_tests;
42#[cfg(all(test, not(target_family = "wasm")))]
43mod long_click_tests;
44#[cfg(all(test, not(target_family = "wasm")))]
45mod native_tests;
46#[cfg(all(test, not(target_family = "wasm")))]
47mod reflow_tests;
48#[cfg(all(test, not(target_family = "wasm")))]
49mod selection_tests;
50#[cfg(all(test, not(target_family = "wasm")))]
51mod shape_tests;
52
53/// Vertical placement of content inside a list row.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum ListItemVerticalAlignment {
56    /// Center shorter rows and top-align taller rows.
57    #[default]
58    Auto,
59    /// Align all content at the top inset.
60    Top,
61    /// Center all content vertically.
62    Center,
63    /// Align all content at the bottom inset.
64    Bottom,
65}
66
67/// Colors used by list items in different states.
68#[derive(Clone, PartialEq, Copy, Debug)]
69pub struct ListItemColors {
70    /// Container color when enabled and not selected.
71    pub container_color: Color,
72    /// Headline text color when enabled and not selected.
73    pub headline_color: Color,
74    /// Overline text color when enabled and not selected.
75    pub overline_color: Color,
76    /// Supporting text color when enabled and not selected.
77    pub supporting_color: Color,
78    /// Leading slot content color when enabled and not selected.
79    pub leading_color: Color,
80    /// Trailing slot content color when enabled and not selected.
81    pub trailing_color: Color,
82    /// Container color when disabled.
83    pub disabled_container_color: Color,
84    /// Headline text color when disabled.
85    pub disabled_headline_color: Color,
86    /// Overline text color when disabled.
87    pub disabled_overline_color: Color,
88    /// Supporting text color when disabled.
89    pub disabled_supporting_color: Color,
90    /// Leading slot content color when disabled.
91    pub disabled_leading_color: Color,
92    /// Trailing slot content color when disabled.
93    pub disabled_trailing_color: Color,
94    /// Container color when selected.
95    pub selected_container_color: Color,
96    /// Headline text color when selected.
97    pub selected_headline_color: Color,
98    /// Overline text color when selected.
99    pub selected_overline_color: Color,
100    /// Supporting text color when selected.
101    pub selected_supporting_color: Color,
102    /// Leading slot content color when selected.
103    pub selected_leading_color: Color,
104    /// Trailing slot content color when selected.
105    pub selected_trailing_color: Color,
106    /// Container color while dragged.
107    pub dragged_container_color: Color,
108    /// Headline color while dragged.
109    pub dragged_headline_color: Color,
110    /// Overline color while dragged.
111    pub dragged_overline_color: Color,
112    /// Supporting text color while dragged.
113    pub dragged_supporting_color: Color,
114    /// Leading content color while dragged.
115    pub dragged_leading_color: Color,
116    /// Trailing content color while dragged.
117    pub dragged_trailing_color: Color,
118}
119
120impl ListItemColors {
121    fn container_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
122        if !enabled {
123            self.disabled_container_color
124        } else if dragged {
125            self.dragged_container_color
126        } else if selected {
127            self.selected_container_color
128        } else {
129            self.container_color
130        }
131    }
132
133    fn headline_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
134        if !enabled {
135            self.disabled_headline_color
136        } else if dragged {
137            self.dragged_headline_color
138        } else if selected {
139            self.selected_headline_color
140        } else {
141            self.headline_color
142        }
143    }
144
145    fn overline_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
146        if !enabled {
147            self.disabled_overline_color
148        } else if dragged {
149            self.dragged_overline_color
150        } else if selected {
151            self.selected_overline_color
152        } else {
153            self.overline_color
154        }
155    }
156
157    fn supporting_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
158        if !enabled {
159            self.disabled_supporting_color
160        } else if dragged {
161            self.dragged_supporting_color
162        } else if selected {
163            self.selected_supporting_color
164        } else {
165            self.supporting_color
166        }
167    }
168
169    fn leading_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
170        if !enabled {
171            self.disabled_leading_color
172        } else if dragged {
173            self.dragged_leading_color
174        } else if selected {
175            self.selected_leading_color
176        } else {
177            self.leading_color
178        }
179    }
180
181    fn trailing_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
182        if !enabled {
183            self.disabled_trailing_color
184        } else if dragged {
185            self.dragged_trailing_color
186        } else if selected {
187            self.selected_trailing_color
188        } else {
189            self.trailing_color
190        }
191    }
192}
193
194/// Default tokens for list item components.
195pub struct ListItemDefaults;
196
197impl ListItemDefaults {
198    /// Minimum height for a one-line list item container.
199    pub const MIN_HEIGHT_ONE_LINE: Dp = Dp(56.0);
200    /// Minimum height for a two-line list item container.
201    pub const MIN_HEIGHT_TWO_LINE: Dp = Dp(72.0);
202    /// Minimum height for a three-line list item container.
203    pub const MIN_HEIGHT_THREE_LINE: Dp = Dp(88.0);
204    /// Minimum size for leading content slots.
205    pub const LEADING_MIN_SIZE: Dp = Dp(24.0);
206    /// Minimum size for trailing content slots.
207    pub const TRAILING_MIN_SIZE: Dp = Dp(24.0);
208    /// Default icon size within an Expressive list item.
209    pub const ICON_SIZE: Dp = Dp(20.0);
210    /// Vertical gap between segmented list items.
211    pub const SEGMENTED_GAP: Dp = Dp(2.0);
212    /// Spacing between leading/trailing slots and the main text column.
213    pub const INTERNAL_SPACING: Dp = Dp(12.0);
214    /// Default content padding applied inside the list item container.
215    pub const CONTENT_PADDING: Padding = Padding::new(Dp(16.0), Dp(10.0), Dp(16.0), Dp(10.0));
216    /// Default tonal elevation for list items.
217    pub const TONAL_ELEVATION: Dp = Dp(0.0);
218    /// Default shadow elevation for list items.
219    pub const SHADOW_ELEVATION: Dp = Dp(0.0);
220
221    /// Default container shape for list items.
222    pub fn shape() -> Shape {
223        let corner = RoundedCorner::manual(Dp(4.0), 2.0);
224        Shape::RoundedRectangle {
225            top_left: corner,
226            top_right: corner,
227            bottom_right: corner,
228            bottom_left: corner,
229        }
230    }
231
232    /// Default colors for list items derived from the current theme.
233    pub fn colors(scheme: &MaterialColorScheme) -> ListItemColors {
234        let scheme = scheme.clone();
235        let disabled = scheme
236            .on_surface
237            .with_alpha(MaterialAlpha::DISABLED_CONTENT);
238        ListItemColors {
239            container_color: scheme.surface,
240            headline_color: scheme.on_surface,
241            overline_color: scheme.on_surface_variant,
242            supporting_color: scheme.on_surface_variant,
243            leading_color: scheme.on_surface_variant,
244            trailing_color: scheme.on_surface_variant,
245            disabled_container_color: scheme.surface,
246            disabled_headline_color: disabled,
247            disabled_overline_color: disabled,
248            disabled_supporting_color: disabled,
249            disabled_leading_color: disabled,
250            disabled_trailing_color: disabled,
251            selected_container_color: scheme.secondary_container,
252            selected_headline_color: scheme.on_secondary_container,
253            selected_overline_color: scheme.on_secondary_container,
254            selected_supporting_color: scheme.on_secondary_container,
255            selected_leading_color: scheme.on_secondary_container,
256            selected_trailing_color: scheme.on_secondary_container,
257            dragged_container_color: scheme.tertiary_container,
258            dragged_headline_color: scheme.on_tertiary_container,
259            dragged_overline_color: scheme.on_tertiary_container,
260            dragged_supporting_color: scheme.on_tertiary_container,
261            dragged_leading_color: scheme.on_tertiary_container,
262            dragged_trailing_color: scheme.on_tertiary_container,
263        }
264    }
265
266    /// Compute the minimum container height based on content lines.
267    pub fn min_height(has_overline: bool, has_supporting: bool) -> Dp {
268        if has_overline && has_supporting {
269            Self::MIN_HEIGHT_THREE_LINE
270        } else if has_overline || has_supporting {
271            Self::MIN_HEIGHT_TWO_LINE
272        } else {
273            Self::MIN_HEIGHT_ONE_LINE
274        }
275    }
276}
277
278/// # list_item
279///
280/// Render an adaptive Material list row for settings, inboxes, and selection
281/// lists.
282///
283/// ## Usage
284///
285/// Present list rows in settings, inboxes, or preference screens.
286///
287/// ## Parameters
288///
289/// - `modifier` — optional modifier chain applied to the list item container.
290/// - `enabled` — optional enabled state; defaults to `true`.
291/// - `selected` — externally controlled selection or checked state.
292/// - `selection_mode` — action, single-selection, or multiple-selection
293///   behavior. The owner maintains exclusivity between single-selection rows.
294/// - `on_selection_change` — requested selection in Single/Multiple modes;
295///   takes precedence over `on_click`. Read latest application state in
296///   callbacks.
297/// - `headline` — primary headline text when `content` is absent.
298/// - `content` — optional custom main content, replacing `headline`.
299/// - `overline_content` — optional custom content replacing `overline_text`.
300/// - `supporting_content` — optional custom content replacing
301///   `supporting_text`. Custom slots inherit their role typography and color.
302///   Supply explicit accessibility labels and descriptions when their meaning
303///   is not text.
304/// - `overline_text` — optional overline text.
305/// - `supporting_text` — optional supporting text.
306/// - `leading` — optional leading slot.
307/// - `trailing` — optional trailing slot.
308/// - `colors` — optional color palette override.
309/// - `shape` — optional container shape override. A fixed shape overrides
310///   position and state shapes.
311/// - `shapes` — optional shapes for resting and active states.
312/// - `position` — row position within a segmented list; defaults to standalone.
313///   Place segmented rows with `ListItemDefaults::SEGMENTED_GAP` between them.
314/// - `dragged` — apply drag colors and shape during an externally managed drag.
315/// - `content_padding` — optional inner padding override.
316/// - `tonal_elevation` — optional tonal elevation override.
317/// - `shadow_elevation` — optional shadow elevation override.
318/// - `min_height` — optional minimum height override.
319/// - `vertical_alignment` — content alignment; short rows center by default and
320///   taller rows align to their top inset.
321/// - `on_long_click` — optional long-press action; successful holds suppress
322///   ordinary clicks and selection requests. Also exposed as a context action.
323/// - `on_click` — action callback, also used as a selection-mode fallback when
324///   `on_selection_change` is absent.
325/// - `interaction_state` — optional shared interaction state.
326/// - `accessibility_label` — optional accessibility label.
327/// - `accessibility_description` — optional accessibility description.
328///
329/// ## Examples
330///
331/// ```
332/// use tessera_components::{
333///     list_item::{ListItemSelectionMode, list_item},
334///     text::text,
335/// };
336/// use tessera_ui::{remember, tessera};
337/// # use tessera_components::theme::{MaterialTheme, material_theme};
338///
339/// #[tessera]
340/// fn demo() {
341///     let selected = remember(|| false);
342///     material_theme()
343///         .theme(|| MaterialTheme::default())
344///         .child(move || {
345///             list_item()
346///                 .selection_mode(ListItemSelectionMode::Multiple)
347///                 .selected(selected.get())
348///                 .on_selection_change(move |value| selected.set(value))
349///                 .accessibility_label("Inbox")
350///                 .content(|| {
351///                     text("Inbox");
352///                 })
353///                 .supporting_text("3 new messages");
354///         });
355/// }
356///
357/// # tessera_ui::testing::layout_test(demo).run();
358/// ```
359#[tessera]
360pub fn list_item(
361    modifier: Option<Modifier>,
362    enabled: Option<bool>,
363    selected: Option<bool>,
364    selection_mode: Option<ListItemSelectionMode>,
365    on_selection_change: Option<CallbackWith<bool>>,
366    #[prop(into)] headline: Option<String>,
367    content: Option<RenderSlot>,
368    overline_content: Option<RenderSlot>,
369    supporting_content: Option<RenderSlot>,
370    #[prop(into)] overline_text: Option<String>,
371    #[prop(into)] supporting_text: Option<String>,
372    leading: Option<RenderSlot>,
373    trailing: Option<RenderSlot>,
374    colors: Option<ListItemColors>,
375    shape: Option<Shape>,
376    shapes: Option<ListItemShapes>,
377    position: Option<ListItemPosition>,
378    dragged: Option<bool>,
379    content_padding: Option<Padding>,
380    tonal_elevation: Option<Dp>,
381    shadow_elevation: Option<Dp>,
382    min_height: Option<Dp>,
383    vertical_alignment: Option<ListItemVerticalAlignment>,
384    on_click: Option<Callback>,
385    on_long_click: Option<Callback>,
386    interaction_state: Option<State<InteractionState>>,
387    #[prop(into)] accessibility_label: Option<String>,
388    #[prop(into)] accessibility_description: Option<String>,
389) {
390    let theme = use_context::<MaterialTheme>()
391        .expect("MaterialTheme must be provided")
392        .get();
393    let typography = theme.typography;
394    let selection_mode = selection_mode.unwrap_or_default();
395    let modifier = modifier
396        .unwrap_or_default()
397        .push_semantics(ListItemSelectionSemantics {
398            mode: selection_mode,
399            selected,
400        });
401    let enabled = enabled.unwrap_or(true);
402    let selected = selected.unwrap_or(false);
403    let action = ListItemActivation {
404        mode: selection_mode,
405        selected,
406        on_click,
407        on_selection_change,
408    };
409    let has_on_click = action.has_handler();
410    let activation = remember(|| action.clone());
411    if activation.get() != action {
412        activation.set(action);
413    }
414    let on_activate = Callback::new(move || activation.get().activate());
415    let dragged = dragged.unwrap_or(false);
416    let headline = headline.unwrap_or_default();
417    let colors = colors.unwrap_or_else(|| ListItemDefaults::colors(&theme.color_scheme));
418    let interaction = if let Some(interaction) = interaction_state {
419        interaction
420    } else {
421        remember(InteractionState::new)
422    };
423    let interactive = enabled && (has_on_click || on_long_click.is_some());
424    let shapes = shapes
425        .unwrap_or_else(|| ListItemShapes::from_theme(&theme))
426        .segmented(position.unwrap_or_default(), theme.shapes.large);
427    let target = if let Some(shape) = shape {
428        shape
429    } else if interactive && interaction.with(InteractionState::is_pressed) {
430        shapes.pressed_shape
431    } else if dragged && enabled {
432        shapes.dragged_shape
433    } else if selected {
434        shapes.selected_shape
435    } else if interactive && interaction.with(InteractionState::is_focused) {
436        shapes.focused_shape
437    } else if interactive && interaction.with(InteractionState::is_hovered) {
438        shapes.hovered_shape
439    } else {
440        shapes.shape
441    };
442    let motion = remember(|| ShapeMotion::new(target));
443    let mut next = motion.get();
444    next.retarget(target, enabled, current_frame_nanos());
445    if next != motion.get() {
446        motion.set(next);
447    }
448    let content_padding = content_padding.unwrap_or(ListItemDefaults::CONTENT_PADDING);
449    let tonal_elevation = tonal_elevation.unwrap_or(ListItemDefaults::TONAL_ELEVATION);
450    let shadow_elevation = shadow_elevation.unwrap_or(ListItemDefaults::SHADOW_ELEVATION);
451
452    let overline_text = overline_text.filter(|value| !value.is_empty());
453    let supporting_text = supporting_text.filter(|value| !value.is_empty());
454    let has_overline = overline_text.is_some() || overline_content.is_some();
455    let has_supporting = supporting_text.is_some() || supporting_content.is_some();
456
457    let target_colors = [
458        colors.container_color(enabled, selected, dragged),
459        colors.headline_color(enabled, selected, dragged),
460        colors.overline_color(enabled, selected, dragged),
461        colors.supporting_color(enabled, selected, dragged),
462        colors.leading_color(enabled, selected, dragged),
463        colors.trailing_color(enabled, selected, dragged),
464    ];
465    let color_motion = remember(|| color_motion::ListColorsMotion::new(target_colors));
466    let mut next_colors = color_motion.get();
467    next_colors.retarget(target_colors, current_frame_nanos());
468    if next_colors != color_motion.get() {
469        color_motion.set(next_colors);
470    }
471    if next.is_animating() || next_colors.is_animating() {
472        receive_frame_nanos(move |frame| {
473            let mut next = motion.get();
474            next.advance(frame);
475            if next != motion.get() {
476                motion.set(next);
477            }
478            let mut next_colors = color_motion.get();
479            next_colors.advance(frame);
480            if next_colors != color_motion.get() {
481                color_motion.set(next_colors);
482            }
483            if next.is_animating() || next_colors.is_animating() {
484                tessera_ui::FrameNanosControl::Continue
485            } else {
486                tessera_ui::FrameNanosControl::Stop
487            }
488        });
489    }
490    let [
491        container_color,
492        headline_color,
493        overline_color,
494        supporting_color,
495        leading_color,
496        trailing_color,
497    ] = next_colors.value();
498
499    let accessibility_label = accessibility_label
500        .or_else(|| (content.is_none() && !headline.is_empty()).then(|| headline.clone()));
501    let accessibility_description = accessibility_description
502        .or_else(|| {
503            if supporting_content.is_none() {
504                supporting_text.clone()
505            } else {
506                None
507            }
508        })
509        .or_else(|| {
510            if overline_content.is_none() {
511                overline_text.clone()
512            } else {
513                None
514            }
515        });
516    let elevation = (shadow_elevation.0 > 0.0).then_some(shadow_elevation);
517
518    surface()
519        .modifier(modifier)
520        .style(SurfaceStyle::Filled {
521            color: container_color,
522        })
523        .shape(motion.get().shape())
524        .content_color(headline_color)
525        .enabled(enabled)
526        .ripple_color(headline_color)
527        .tonal_elevation(tonal_elevation)
528        .accessibility_role(selection_mode.role())
529        .elevation_optional(elevation)
530        .interaction_state(interaction)
531        .on_click_optional(has_on_click.then_some(on_activate))
532        .on_long_click_optional(on_long_click)
533        .accessibility_focusable(interactive)
534        .accessibility_label_optional(accessibility_label)
535        .accessibility_description_optional(accessibility_description)
536        .child(move || {
537            let headline = headline.clone();
538            let overline_text = overline_text.clone();
539            let supporting_text = supporting_text.clone();
540            layout()
541                .policy(ListItemLayout {
542                    padding: content_padding,
543                    min_height,
544                    has_overline,
545                    has_supporting,
546                    supporting_line_height: typography
547                        .body_medium
548                        .line_height
549                        .unwrap_or(Dp(typography.body_medium.font_size.0 * 1.2)),
550                    vertical_alignment: vertical_alignment.unwrap_or_default(),
551                })
552                .child(move || {
553                    list_item_slot()
554                        .content_optional(leading)
555                        .color(leading_color)
556                        .style(typography.title_medium)
557                        .leading(true);
558                    list_item_slot()
559                        .content_optional(trailing)
560                        .color(trailing_color)
561                        .style(typography.label_small);
562                    list_item_text_line()
563                        .text_value_optional(overline_text.clone())
564                        .content_optional(overline_content)
565                        .style(typography.label_small)
566                        .color(overline_color);
567                    list_item_text_line()
568                        .text_value(headline.clone())
569                        .content_optional(content)
570                        .style(typography.body_large)
571                        .color(headline_color);
572                    list_item_text_line()
573                        .text_value_optional(supporting_text.clone())
574                        .content_optional(supporting_content)
575                        .style(typography.body_medium)
576                        .color(supporting_color);
577                });
578        });
579}
580
581#[tessera]
582fn list_item_text_line(
583    #[prop(into)] text_value: Option<String>,
584    content: Option<RenderSlot>,
585    style: Option<crate::theme::TextStyle>,
586    color: Option<Color>,
587) {
588    let text_value = text_value.unwrap_or_default();
589    if let Some(content) = content {
590        list_item_slot()
591            .content_shared(content)
592            .color_optional(color)
593            .style_optional(style)
594            .main(true);
595    } else if text_value.is_empty() {
596        spacer();
597    } else {
598        text(text_value)
599            .style(style.unwrap_or_default())
600            .color(color.unwrap_or(Color::TRANSPARENT));
601    }
602}
603
604#[tessera]
605fn list_item_slot(
606    content: Option<RenderSlot>,
607    color: Option<Color>,
608    style: Option<TextStyle>,
609    leading: Option<bool>,
610    main: Option<bool>,
611) {
612    let color = color.unwrap_or(Color::TRANSPARENT);
613    let style = style.unwrap_or_default();
614    let icon_size = ListItemDefaults::ICON_SIZE;
615    provide_context(
616        || ContentColor { current: color },
617        move || {
618            let current = use_context::<ContentColor>().expect("list item content color");
619            if current.get().current != color {
620                current.set(ContentColor { current: color });
621            }
622            provide_context(
623                || style,
624                move || {
625                    let current = use_context::<TextStyle>().expect("list item slot typography");
626                    if current.get() != style {
627                        current.set(style);
628                    }
629                    provide_context(
630                        || IconSize(icon_size),
631                        move || {
632                            let current = use_context::<IconSize>().expect("list item icon size");
633                            if current.get().0 != icon_size {
634                                current.set(IconSize(icon_size));
635                            }
636                            let padding = if content.is_none() || main.unwrap_or(false) {
637                                Padding::default()
638                            } else if leading.unwrap_or(false) {
639                                Padding::new(
640                                    Dp::ZERO,
641                                    Dp::ZERO,
642                                    ListItemDefaults::INTERNAL_SPACING,
643                                    Dp::ZERO,
644                                )
645                            } else {
646                                Padding::new(
647                                    ListItemDefaults::INTERNAL_SPACING,
648                                    Dp::ZERO,
649                                    Dp::ZERO,
650                                    Dp::ZERO,
651                                )
652                            };
653                            boxed()
654                                .alignment(if main.unwrap_or(false) {
655                                    Alignment::TopStart
656                                } else {
657                                    Alignment::Center
658                                })
659                                .modifier(Modifier::new().padding(padding))
660                                .children(move || {
661                                    if let Some(content) = content {
662                                        content.render();
663                                    }
664                                });
665                        },
666                    );
667                },
668            );
669        },
670    );
671}