Skip to main content

tessera_components/
card.rs

1//! Material Design card primitives.
2//!
3//! ## Usage
4//!
5//! Group related content into a single, elevated or outlined container.
6
7use tessera_ui::{
8    Callback, Color, Dp, Modifier, RenderSlot, State, accesskit::Role, receive_frame_nanos,
9    remember, tessera, use_context,
10};
11
12use crate::{
13    animation::Animatable,
14    modifier::InteractionState,
15    shape_def::Shape,
16    surface::{SurfaceStyle, surface},
17    theme::{ContentColor, MaterialAlpha, MaterialTheme, content_color_for},
18};
19
20const DEFAULT_SPATIAL_DAMPING_RATIO: f32 = 0.9;
21const DEFAULT_SPATIAL_STIFFNESS: f32 = 700.0;
22
23#[cfg(test)]
24mod tests;
25
26fn composite_over(base: Color, overlay: Color) -> Color {
27    let overlay_a = overlay.a.clamp(0.0, 1.0);
28    let base_a = base.a.clamp(0.0, 1.0);
29    let out_a = overlay_a + base_a * (1.0 - overlay_a);
30    if out_a <= 0.0 {
31        return Color::TRANSPARENT;
32    }
33
34    let r = (overlay.r * overlay_a + base.r * base_a * (1.0 - overlay_a)) / out_a;
35    let g = (overlay.g * overlay_a + base.g * base_a * (1.0 - overlay_a)) / out_a;
36    let b = (overlay.b * overlay_a + base.b * base_a * (1.0 - overlay_a)) / out_a;
37    Color::new(r, g, b, out_a)
38}
39
40#[derive(Clone, PartialEq, Debug)]
41struct CardElevationSpring {
42    last_frame_nanos: Option<u64>,
43    spring: Animatable,
44}
45
46impl CardElevationSpring {
47    fn new(initial: Dp) -> Self {
48        Self {
49            last_frame_nanos: None,
50            spring: Animatable::spring(
51                initial.0 as f32,
52                DEFAULT_SPATIAL_STIFFNESS.sqrt(),
53                DEFAULT_SPATIAL_DAMPING_RATIO,
54            ),
55        }
56    }
57
58    fn set_target(&mut self, target: Dp) {
59        if self.spring.target() != target.0 as f32 {
60            self.last_frame_nanos = None;
61        }
62        self.spring.set_target(target.0 as f32);
63    }
64
65    fn snap_to(&mut self, target: Dp) {
66        *self = Self::new(target);
67    }
68
69    fn tick(&mut self, frame_nanos: u64) {
70        let dt = if let Some(last_frame_nanos) = self.last_frame_nanos {
71            frame_nanos.saturating_sub(last_frame_nanos) as f32 / 1_000_000_000.0
72        } else {
73            1.0 / 60.0
74        };
75        self.last_frame_nanos = Some(frame_nanos);
76        self.spring.tick(dt);
77    }
78
79    fn is_animating(&self) -> bool {
80        !self.spring.is_idle()
81    }
82
83    fn value_dp(&self) -> Dp {
84        Dp(self.spring.value() as f64)
85    }
86}
87
88/// Visual variants supported by [`card`].
89#[derive(Clone, PartialEq, Copy, Debug, Default)]
90pub enum CardVariant {
91    /// Filled cards provide subtle separation from the background.
92    #[default]
93    Filled,
94    /// Elevated cards provide more emphasis via shadow elevation.
95    Elevated,
96    /// Outlined cards provide emphasis via a border stroke.
97    Outlined,
98}
99
100/// Represents the container and content colors used in a card in different
101/// states.
102#[derive(Clone, PartialEq, Copy, Debug)]
103pub struct CardColors {
104    /// Container color used when enabled.
105    pub container_color: Color,
106    /// Content color used when enabled.
107    pub content_color: Color,
108    /// Container color used when disabled.
109    pub disabled_container_color: Color,
110    /// Content color used when disabled.
111    pub disabled_content_color: Color,
112}
113
114impl CardColors {
115    fn container_color(self, enabled: bool) -> Color {
116        if enabled {
117            self.container_color
118        } else {
119            self.disabled_container_color
120        }
121    }
122
123    fn content_color(self, enabled: bool) -> Color {
124        if enabled {
125            self.content_color
126        } else {
127            self.disabled_content_color
128        }
129    }
130}
131
132/// Represents a border stroke for card containers.
133#[derive(Clone, PartialEq, Copy, Debug)]
134pub struct CardBorder {
135    /// Border width.
136    pub width: Dp,
137    /// Border color.
138    pub color: Color,
139}
140
141/// Represents the elevation for a card in different states.
142#[derive(Clone, PartialEq, Copy, Debug)]
143pub struct CardElevation {
144    default_elevation: Dp,
145    pressed_elevation: Dp,
146    focused_elevation: Dp,
147    hovered_elevation: Dp,
148    dragged_elevation: Dp,
149    disabled_elevation: Dp,
150}
151
152impl CardElevation {
153    fn default_elevation(self) -> Dp {
154        self.default_elevation
155    }
156
157    fn target(self, enabled: bool, interaction_state: Option<State<InteractionState>>) -> Dp {
158        if !enabled {
159            return self.disabled_elevation;
160        }
161
162        let Some(state) = interaction_state else {
163            return self.default_elevation;
164        };
165
166        state.with(|s| {
167            if s.is_dragged() {
168                self.dragged_elevation
169            } else if s.is_pressed() {
170                self.pressed_elevation
171            } else if s.is_focused() {
172                self.focused_elevation
173            } else if s.is_hovered() {
174                self.hovered_elevation
175            } else {
176                self.default_elevation
177            }
178        })
179    }
180}
181
182/// Default values for card components.
183pub struct CardDefaults;
184
185impl CardDefaults {
186    /// Opacity applied to disabled container overlays.
187    pub const DISABLED_CONTAINER_OPACITY: f32 = 0.38;
188    /// Opacity applied to disabled content.
189    pub const DISABLED_CONTENT_ALPHA: f32 = MaterialAlpha::DISABLED_CONTENT;
190    /// Border opacity for disabled outlined cards.
191    pub const DISABLED_OUTLINE_ALPHA: f32 = 0.12;
192
193    /// Default filled card shape.
194    pub fn shape(theme: &MaterialTheme) -> Shape {
195        theme.shapes.medium
196    }
197
198    /// Default elevated card shape.
199    pub fn elevated_shape(theme: &MaterialTheme) -> Shape {
200        theme.shapes.medium
201    }
202
203    /// Default outlined card shape.
204    pub fn outlined_shape(theme: &MaterialTheme) -> Shape {
205        theme.shapes.medium
206    }
207
208    /// Default elevation values for filled cards.
209    pub fn card_elevation() -> CardElevation {
210        CardElevation {
211            default_elevation: Dp(0.0),
212            pressed_elevation: Dp(0.0),
213            focused_elevation: Dp(0.0),
214            hovered_elevation: Dp(1.0),
215            dragged_elevation: Dp(3.0),
216            disabled_elevation: Dp(0.0),
217        }
218    }
219
220    /// Default elevation values for elevated cards.
221    pub fn elevated_card_elevation() -> CardElevation {
222        CardElevation {
223            default_elevation: Dp(1.0),
224            pressed_elevation: Dp(1.0),
225            focused_elevation: Dp(1.0),
226            hovered_elevation: Dp(2.0),
227            dragged_elevation: Dp(4.0),
228            disabled_elevation: Dp(1.0),
229        }
230    }
231
232    /// Default elevation values for outlined cards.
233    pub fn outlined_card_elevation() -> CardElevation {
234        Self::card_elevation()
235    }
236
237    /// Default colors for filled cards.
238    pub fn card_colors(theme: &MaterialTheme, content_color: Color) -> CardColors {
239        let scheme = &theme.color_scheme;
240        let container = scheme.surface_container_highest;
241        let content = content_color_for(container, scheme).unwrap_or(content_color);
242        let disabled_overlay = scheme
243            .surface_variant
244            .with_alpha(Self::DISABLED_CONTAINER_OPACITY);
245        let disabled_container = composite_over(container, disabled_overlay);
246        CardColors {
247            container_color: container,
248            content_color: content,
249            disabled_container_color: disabled_container,
250            disabled_content_color: content.with_alpha(Self::DISABLED_CONTENT_ALPHA),
251        }
252    }
253
254    /// Default colors for elevated cards.
255    pub fn elevated_card_colors(theme: &MaterialTheme, content_color: Color) -> CardColors {
256        let scheme = &theme.color_scheme;
257        let container = scheme.surface_container_low;
258        let content = content_color_for(container, scheme).unwrap_or(content_color);
259        CardColors {
260            container_color: container,
261            content_color: content,
262            disabled_container_color: scheme.surface,
263            disabled_content_color: content.with_alpha(Self::DISABLED_CONTENT_ALPHA),
264        }
265    }
266
267    /// Default colors for outlined cards.
268    pub fn outlined_card_colors(theme: &MaterialTheme, content_color: Color) -> CardColors {
269        let scheme = &theme.color_scheme;
270        let container = scheme.surface;
271        let content = content_color_for(container, scheme).unwrap_or(content_color);
272        CardColors {
273            container_color: container,
274            content_color: content,
275            disabled_container_color: container,
276            disabled_content_color: content.with_alpha(Self::DISABLED_CONTENT_ALPHA),
277        }
278    }
279
280    /// Default border stroke for outlined cards.
281    pub fn outlined_card_border(theme: &MaterialTheme, enabled: bool) -> CardBorder {
282        let scheme = &theme.color_scheme;
283        let color = if enabled {
284            scheme.outline_variant
285        } else {
286            composite_over(
287                scheme.surface_container_low,
288                scheme.outline.with_alpha(Self::DISABLED_OUTLINE_ALPHA),
289            )
290        };
291        CardBorder {
292            width: Dp(1.0),
293            color,
294        }
295    }
296}
297
298/// # card
299///
300/// Renders a Material card container, optionally clickable and with animated
301/// elevation.
302///
303/// ## Usage
304///
305/// Group related information and actions into a visually distinct container.
306///
307/// ## Parameters
308///
309/// - `modifier` — modifier chain applied to the card subtree.
310/// - `variant` — optional card variant controlling default tokens.
311/// - `enabled` — optional enabled flag.
312/// - `on_click` — optional click callback.
313/// - `interaction_state` — optional shared interaction state.
314/// - `shape` — optional shape override.
315/// - `colors` — optional color override set.
316/// - `elevation` — optional elevation override.
317/// - `border` — optional border override.
318/// - `accessibility_label` — optional accessible name for the card's main
319///   action.
320/// - `content` — optional content render slot.
321///
322/// ## Examples
323///
324/// ```
325/// use tessera_components::card::card;
326/// use tessera_ui::tessera;
327/// # use tessera_components::theme::{MaterialTheme, material_theme};
328///
329/// #[tessera]
330/// fn component() {
331/// #     material_theme()
332/// #         .theme(|| MaterialTheme::default())
333/// #         .child(|| {
334///     card().filled().content(|| {});
335/// #         });
336/// }
337///
338/// # tessera_ui::testing::layout_test(component).run();
339/// ```
340#[tessera]
341pub fn card(
342    modifier: Option<Modifier>,
343    variant: Option<CardVariant>,
344    enabled: Option<bool>,
345    on_click: Option<Callback>,
346    interaction_state: Option<State<InteractionState>>,
347    shape: Option<Shape>,
348    colors: Option<CardColors>,
349    elevation: Option<CardElevation>,
350    border: Option<CardBorder>,
351    #[prop(into)] accessibility_label: Option<String>,
352    content: Option<RenderSlot>,
353) {
354    let modifier = modifier.unwrap_or_default();
355    let variant = variant.unwrap_or_default();
356    let enabled = enabled.unwrap_or(true);
357    let content = content.unwrap_or_else(RenderSlot::empty);
358
359    let theme = use_context::<MaterialTheme>()
360        .expect("MaterialTheme must be provided")
361        .get();
362    let content_color = use_context::<ContentColor>()
363        .map(|c| c.get().current)
364        .unwrap_or(ContentColor::default().current);
365
366    let shape = shape.unwrap_or_else(|| match variant {
367        CardVariant::Filled => CardDefaults::shape(&theme),
368        CardVariant::Elevated => CardDefaults::elevated_shape(&theme),
369        CardVariant::Outlined => CardDefaults::outlined_shape(&theme),
370    });
371
372    let colors = colors.unwrap_or_else(|| match variant {
373        CardVariant::Filled => CardDefaults::card_colors(&theme, content_color),
374        CardVariant::Elevated => CardDefaults::elevated_card_colors(&theme, content_color),
375        CardVariant::Outlined => CardDefaults::outlined_card_colors(&theme, content_color),
376    });
377
378    let elevation = elevation.unwrap_or_else(|| match variant {
379        CardVariant::Filled => CardDefaults::card_elevation(),
380        CardVariant::Elevated => CardDefaults::elevated_card_elevation(),
381        CardVariant::Outlined => CardDefaults::outlined_card_elevation(),
382    });
383
384    let clickable = on_click.is_some();
385    let interaction_state = if clickable {
386        Some(if let Some(state) = interaction_state {
387            state
388        } else {
389            remember(InteractionState::new)
390        })
391    } else {
392        None
393    };
394    if !enabled && let Some(state) = interaction_state {
395        if state.with(|state| *state != InteractionState::default()) {
396            state.set(InteractionState::default());
397        }
398    }
399    let border = match border {
400        Some(border) => Some(border),
401        None if matches!(variant, CardVariant::Outlined) => {
402            let mut border = CardDefaults::outlined_card_border(&theme, enabled);
403            if enabled
404                && interaction_state.is_some_and(|state| {
405                    state.with(|state| {
406                        state.is_focused() && !state.is_pressed() && !state.is_dragged()
407                    })
408                })
409            {
410                border.color = theme.color_scheme.on_surface;
411            }
412            Some(border)
413        }
414        None => None,
415    };
416
417    let elevation_spring = remember(|| CardElevationSpring::new(elevation.default_elevation()));
418
419    let target = elevation.target(enabled, interaction_state);
420    let should_update_spring = elevation_spring.with(|spring| {
421        let mut next = spring.clone();
422        next.set_target(target);
423        if !enabled || !clickable {
424            next.snap_to(target);
425        }
426        next != *spring
427    });
428    if should_update_spring {
429        elevation_spring.with_mut(|spring| {
430            spring.set_target(target);
431            if !enabled || !clickable {
432                spring.snap_to(target);
433            }
434        });
435    }
436
437    let should_schedule_frame = elevation_spring.with(|spring| spring.is_animating());
438
439    if should_schedule_frame {
440        receive_frame_nanos(move |frame_nanos| {
441            let is_animating = elevation_spring.with_mut(|spring| {
442                spring.tick(frame_nanos);
443                spring.is_animating()
444            });
445            if is_animating {
446                tessera_ui::FrameNanosControl::Continue
447            } else {
448                tessera_ui::FrameNanosControl::Stop
449            }
450        });
451    }
452
453    let shadow_elevation = if clickable {
454        elevation_spring.with(|s| s.value_dp())
455    } else {
456        elevation.default_elevation()
457    };
458
459    let container_color = colors.container_color(enabled);
460    let content_color = colors.content_color(enabled);
461    let style = match border {
462        Some(border) => SurfaceStyle::FilledOutlined {
463            fill_color: container_color,
464            border_color: border.color,
465            border_width: border.width,
466        },
467        None => SurfaceStyle::Filled {
468            color: container_color,
469        },
470    };
471
472    surface()
473        .shape(shape)
474        .clip_content(true)
475        .modifier(modifier)
476        .content_color(content_color)
477        .elevation(shadow_elevation)
478        .enabled(enabled)
479        .style(style)
480        .interaction_state_optional(interaction_state)
481        .on_click_optional(on_click)
482        .accessibility_role_optional(clickable.then_some(Role::Button))
483        .accessibility_label_optional(accessibility_label)
484        .accessibility_focusable_optional(clickable.then_some(enabled))
485        .child_shared(content);
486}
487
488impl CardBuilder {
489    /// Applies the filled card preset.
490    pub fn filled(&self) -> Self {
491        self.variant(CardVariant::Filled)
492    }
493
494    /// Applies the elevated card preset.
495    pub fn elevated(&self) -> Self {
496        self.variant(CardVariant::Elevated)
497    }
498
499    /// Applies the outlined card preset.
500    pub fn outlined(&self) -> Self {
501        self.variant(CardVariant::Outlined)
502    }
503}