Skip to main content

tessera_components/
chip.rs

1//! Material Design chip components.
2//!
3//! ## Usage
4//!
5//! Present compact actions, filters, or input tokens in dense UIs.
6use tessera_ui::{
7    Callback, Color, Dp, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
8    accesskit::{Role, Toggled},
9    current_frame_nanos,
10    layout::{MeasureScope, layout},
11    provide_context, receive_frame_nanos, remember, tessera, use_context,
12};
13
14use crate::{
15    alignment::{Alignment, CrossAxisAlignment},
16    animation::shape_motion::ShapeMotion,
17    boxed::boxed,
18    icon::icon,
19    image::image,
20    material_icons::filled,
21    modifier::{InteractionState, ModifierExt as _, Padding},
22    painter::Painter,
23    row::row,
24    shape_def::{RoundedCorner, Shape},
25    spacer::spacer,
26    surface::{SurfaceStyle, surface},
27    text::text,
28    theme::{MaterialAlpha, MaterialColorScheme, MaterialTheme, TextStyle},
29};
30
31use motion::{Elevation, Interaction};
32
33mod motion;
34
35/// Visual variants supported by [`chip`].
36#[derive(Clone, PartialEq, Copy, Debug, Default)]
37pub enum ChipVariant {
38    /// A compact action chip for context-aware suggestions.
39    #[default]
40    Assist,
41    /// A chip for offering suggested actions or next steps.
42    Suggestion,
43    /// A selectable chip for filtering content.
44    Filter,
45    /// A selectable chip representing input with a remove affordance.
46    Input,
47}
48
49/// Container styles for chips.
50#[derive(Clone, PartialEq, Copy, Debug, Default)]
51pub enum ChipStyle {
52    /// Flat, outlined style.
53    #[default]
54    Flat,
55    /// Elevated style with a subtle shadow.
56    Elevated,
57}
58
59/// Shape behavior for selectable chips.
60#[derive(Clone, Copy, Debug, Default, PartialEq)]
61pub enum ChipShapeStyle {
62    /// Medium corners at rest, rounded ends when selected, and small pressed
63    /// corners.
64    #[default]
65    Expressive,
66    /// Fixed small corners in every selection and interaction state.
67    Standard,
68}
69
70/// Represents the container and content colors used in a chip in different
71/// states.
72#[derive(Clone, PartialEq, Copy, Debug)]
73pub struct ChipColors {
74    /// Container color when enabled and not selected.
75    pub container_color: Color,
76    /// Label color when enabled and not selected.
77    pub label_color: Color,
78    /// Leading icon color when enabled and not selected.
79    pub leading_icon_color: Color,
80    /// Trailing icon color when enabled and not selected.
81    pub trailing_icon_color: Color,
82    /// Container color when disabled and not selected.
83    pub disabled_container_color: Color,
84    /// Label color when disabled and not selected.
85    pub disabled_label_color: Color,
86    /// Leading icon color when disabled and not selected.
87    pub disabled_leading_icon_color: Color,
88    /// Trailing icon color when disabled and not selected.
89    pub disabled_trailing_icon_color: Color,
90    /// Container color when enabled and selected.
91    pub selected_container_color: Color,
92    /// Label color when enabled and selected.
93    pub selected_label_color: Color,
94    /// Leading icon color when enabled and selected.
95    pub selected_leading_icon_color: Color,
96    /// Trailing icon color when enabled and selected.
97    pub selected_trailing_icon_color: Color,
98    /// Container color when disabled and selected.
99    pub disabled_selected_container_color: Color,
100    /// Label color when disabled and selected.
101    pub disabled_selected_label_color: Color,
102    /// Leading icon color when disabled and selected.
103    pub disabled_selected_leading_icon_color: Color,
104    /// Trailing icon color when disabled and selected.
105    pub disabled_selected_trailing_icon_color: Color,
106}
107
108impl ChipColors {
109    fn container_color(self, enabled: bool, selected: bool) -> Color {
110        if enabled {
111            if selected {
112                self.selected_container_color
113            } else {
114                self.container_color
115            }
116        } else if selected {
117            self.disabled_selected_container_color
118        } else {
119            self.disabled_container_color
120        }
121    }
122
123    fn label_color(self, enabled: bool, selected: bool) -> Color {
124        if enabled {
125            if selected {
126                self.selected_label_color
127            } else {
128                self.label_color
129            }
130        } else if selected {
131            self.disabled_selected_label_color
132        } else {
133            self.disabled_label_color
134        }
135    }
136
137    fn leading_icon_color(self, enabled: bool, selected: bool) -> Color {
138        if enabled {
139            if selected {
140                self.selected_leading_icon_color
141            } else {
142                self.leading_icon_color
143            }
144        } else if selected {
145            self.disabled_selected_leading_icon_color
146        } else {
147            self.disabled_leading_icon_color
148        }
149    }
150
151    fn trailing_icon_color(self, enabled: bool, selected: bool) -> Color {
152        if enabled {
153            if selected {
154                self.selected_trailing_icon_color
155            } else {
156                self.trailing_icon_color
157            }
158        } else if selected {
159            self.disabled_selected_trailing_icon_color
160        } else {
161            self.disabled_trailing_icon_color
162        }
163    }
164}
165
166/// Represents a border stroke for chip containers.
167#[derive(Clone, PartialEq, Copy, Debug)]
168pub struct ChipBorder {
169    /// Border width when enabled and not selected.
170    pub width: Dp,
171    /// Border color when enabled and not selected.
172    pub color: Color,
173    /// Border width when enabled and selected.
174    pub selected_width: Dp,
175    /// Border color when enabled and selected.
176    pub selected_color: Color,
177    /// Border color when disabled and not selected.
178    pub disabled_color: Color,
179    /// Border color when disabled and selected.
180    pub disabled_selected_color: Color,
181}
182
183impl ChipBorder {
184    fn resolve(self, enabled: bool, selected: bool) -> Option<(Dp, Color)> {
185        let width = if selected {
186            self.selected_width
187        } else {
188            self.width
189        };
190
191        let color = if enabled {
192            if selected {
193                self.selected_color
194            } else {
195                self.color
196            }
197        } else if selected {
198            self.disabled_selected_color
199        } else {
200            self.disabled_color
201        };
202
203        if width.0 <= 0.0 || color.a <= 0.0 {
204            None
205        } else {
206            Some((width, color))
207        }
208    }
209}
210
211/// Default values for chip components.
212pub struct ChipDefaults;
213
214impl ChipDefaults {
215    /// Minimum height for chips.
216    pub const HEIGHT: Dp = Dp(32.0);
217    /// Default icon size used inside chips.
218    pub const ICON_SIZE: Dp = Dp(18.0);
219    /// Diameter of an input chip avatar.
220    pub const AVATAR_SIZE: Dp = Dp(24.0);
221    /// Spacing between icons and label content.
222    pub const ELEMENT_SPACING: Dp = Dp(8.0);
223    /// Horizontal padding applied to most chip variants.
224    pub const HORIZONTAL_PADDING: Dp = Dp(8.0);
225    /// Input chip horizontal padding when no leading or trailing icon exists.
226    pub const INPUT_EDGE_PADDING: Dp = Dp(4.0);
227    /// Input chip horizontal padding when leading or trailing icon is present.
228    pub const INPUT_ICON_PADDING: Dp = Dp(8.0);
229
230    /// Fixed standard shape for chip containers.
231    pub fn shape() -> Shape {
232        Shape::RoundedRectangle {
233            top_left: RoundedCorner::manual(Dp(8.0), 2.0),
234            top_right: RoundedCorner::manual(Dp(8.0), 2.0),
235            bottom_right: RoundedCorner::manual(Dp(8.0), 2.0),
236            bottom_left: RoundedCorner::manual(Dp(8.0), 2.0),
237        }
238    }
239
240    /// Default colors for chips by variant and style.
241    pub fn colors(
242        scheme: &MaterialColorScheme,
243        variant: ChipVariant,
244        style: ChipStyle,
245    ) -> ChipColors {
246        let scheme = scheme.clone();
247        let disabled_content = scheme
248            .on_surface
249            .with_alpha(MaterialAlpha::DISABLED_CONTENT);
250        let disabled_container = match style {
251            ChipStyle::Elevated => scheme
252                .on_surface
253                .with_alpha(MaterialAlpha::DISABLED_CONTAINER),
254            ChipStyle::Flat => Color::TRANSPARENT,
255        };
256
257        let container = match style {
258            ChipStyle::Elevated => scheme.surface_container_low,
259            ChipStyle::Flat => Color::TRANSPARENT,
260        };
261        match variant {
262            ChipVariant::Assist | ChipVariant::Suggestion => {
263                let label = if variant == ChipVariant::Assist {
264                    scheme.on_surface
265                } else {
266                    scheme.on_surface_variant
267                };
268                let icon = scheme.primary;
269                ChipColors {
270                    container_color: container,
271                    label_color: label,
272                    leading_icon_color: icon,
273                    trailing_icon_color: icon,
274                    disabled_container_color: disabled_container,
275                    disabled_label_color: disabled_content,
276                    disabled_leading_icon_color: disabled_content,
277                    disabled_trailing_icon_color: disabled_content,
278                    selected_container_color: container,
279                    selected_label_color: label,
280                    selected_leading_icon_color: icon,
281                    selected_trailing_icon_color: icon,
282                    disabled_selected_container_color: disabled_container,
283                    disabled_selected_label_color: disabled_content,
284                    disabled_selected_leading_icon_color: disabled_content,
285                    disabled_selected_trailing_icon_color: disabled_content,
286                }
287            }
288            ChipVariant::Filter | ChipVariant::Input => {
289                let disabled_selected_container = scheme
290                    .on_surface
291                    .with_alpha(MaterialAlpha::DISABLED_CONTAINER);
292                ChipColors {
293                    container_color: container,
294                    label_color: scheme.on_surface_variant,
295                    leading_icon_color: if variant == ChipVariant::Filter {
296                        scheme.primary
297                    } else {
298                        scheme.on_surface_variant
299                    },
300                    trailing_icon_color: scheme.on_surface_variant,
301                    disabled_container_color: disabled_container,
302                    disabled_label_color: disabled_content,
303                    disabled_leading_icon_color: disabled_content,
304                    disabled_trailing_icon_color: disabled_content,
305                    selected_container_color: scheme.secondary_container,
306                    selected_label_color: scheme.on_secondary_container,
307                    selected_leading_icon_color: if variant == ChipVariant::Filter {
308                        scheme.on_secondary_container
309                    } else {
310                        scheme.primary
311                    },
312                    selected_trailing_icon_color: scheme.on_secondary_container,
313                    disabled_selected_container_color: disabled_selected_container,
314                    disabled_selected_label_color: disabled_content,
315                    disabled_selected_leading_icon_color: disabled_content,
316                    disabled_selected_trailing_icon_color: disabled_content,
317                }
318            }
319        }
320    }
321
322    /// Default border stroke configuration for chips by variant and style.
323    pub fn border(
324        scheme: &MaterialColorScheme,
325        variant: ChipVariant,
326        style: ChipStyle,
327    ) -> Option<ChipBorder> {
328        if matches!(style, ChipStyle::Elevated) {
329            return None;
330        }
331
332        let scheme = scheme.clone();
333        let outline = scheme.outline_variant;
334        let disabled_outline = scheme
335            .on_surface
336            .with_alpha(MaterialAlpha::DISABLED_CONTAINER);
337
338        let (selected_width, selected_color, disabled_selected_color) = match variant {
339            ChipVariant::Assist | ChipVariant::Suggestion => (Dp(1.0), outline, disabled_outline),
340            ChipVariant::Filter | ChipVariant::Input => {
341                (Dp(0.0), Color::TRANSPARENT, Color::TRANSPARENT)
342            }
343        };
344
345        Some(ChipBorder {
346            width: Dp(1.0),
347            color: outline,
348            selected_width,
349            selected_color,
350            disabled_color: disabled_outline,
351            disabled_selected_color,
352        })
353    }
354
355    /// Default elevation used for elevated chips.
356    pub fn elevation(style: ChipStyle) -> Option<Dp> {
357        match style {
358            ChipStyle::Flat => None,
359            ChipStyle::Elevated => Some(Dp(1.0)),
360        }
361    }
362}
363
364#[derive(Clone)]
365struct ChipResolvedArgs {
366    variant: ChipVariant,
367    style: ChipStyle,
368    label: String,
369    leading_icon: Option<Painter>,
370    avatar: Option<Painter>,
371    trailing_icon: Option<Painter>,
372    selected: bool,
373    enabled: bool,
374    modifier: Modifier,
375    colors: Option<ChipColors>,
376    border: Option<ChipBorder>,
377    shape: Option<Shape>,
378    shape_style: ChipShapeStyle,
379    elevation: Option<Dp>,
380    on_click: Option<Callback>,
381    on_remove: Option<Callback>,
382    remove_accessibility_label: Option<String>,
383    accessibility_label: Option<String>,
384    accessibility_description: Option<String>,
385}
386
387impl ChipBuilder {
388    /// Applies the assist chip preset and updates the visible label.
389    pub fn assist(&self, label: impl Into<String>) -> Self {
390        self.variant(ChipVariant::Assist).label(label.into())
391    }
392
393    /// Applies the suggestion chip preset and updates the visible label.
394    pub fn suggestion(&self, label: impl Into<String>) -> Self {
395        self.variant(ChipVariant::Suggestion).label(label.into())
396    }
397
398    /// Applies the filter chip preset and updates the visible label.
399    pub fn filter(&self, label: impl Into<String>) -> Self {
400        self.variant(ChipVariant::Filter).label(label.into())
401    }
402
403    /// Applies the input chip preset and updates the visible label.
404    pub fn input(&self, label: impl Into<String>) -> Self {
405        self.variant(ChipVariant::Input).label(label.into())
406    }
407
408    /// Sets the leading icon content using any supported icon source.
409    pub fn leading_icon(&self, icon: impl Into<Painter>) -> Self {
410        let value = Some(icon.into());
411        self.props
412            .configure("leading_icon", move |props| props.leading_icon = value);
413        Self { props: self.props }
414    }
415
416    /// Clears the leading icon content.
417    pub fn clear_leading_icon(&self) -> Self {
418        let value = None;
419        self.props
420            .configure("leading_icon", move |props| props.leading_icon = value);
421        Self { props: self.props }
422    }
423
424    /// Sets the trailing icon content using any supported icon source.
425    pub fn trailing_icon(&self, icon: impl Into<Painter>) -> Self {
426        let value = Some(icon.into());
427        self.props
428            .configure("trailing_icon", move |props| props.trailing_icon = value);
429        Self { props: self.props }
430    }
431
432    /// Clears the trailing icon content.
433    pub fn clear_trailing_icon(&self) -> Self {
434        let value = None;
435        self.props
436            .configure("trailing_icon", move |props| props.trailing_icon = value);
437        Self { props: self.props }
438    }
439}
440
441/// # chip
442///
443/// Renders a compact chip for actions, filters, and input tokens with optional
444/// icons.
445///
446/// ## Usage
447///
448/// Use for filters, suggestions, and compact actions in dense layouts.
449///
450/// ## Parameters
451///
452/// - `variant` — optional chip variant controlling default tokens and behavior.
453/// - `style` — optional chip container style.
454/// - `label` — visible chip label text.
455/// - `leading_icon` — optional leading icon content.
456/// - `trailing_icon` — optional trailing icon content.
457/// - `avatar` — optional raster or vector input avatar; takes precedence over
458///   the leading icon and is ignored by other variants.
459/// - `selected` — optional selected state used by selectable variants.
460/// - `enabled` — optional enabled flag.
461/// - `modifier` — modifier chain applied to the chip subtree.
462/// - `colors` — optional chip color override set.
463/// - `border` — optional chip border override.
464/// - `shape` — optional chip shape override.
465/// - `shape_style` — optional selectable-chip shape behavior; defaults to
466///   Expressive and is replaced by an explicit `shape`.
467/// - `elevation` — optional chip elevation override.
468/// - `on_click` — optional body click callback.
469/// - `on_remove` — optional input-chip removal action; independent from body
470///   activation when both callbacks are provided.
471/// - `remove_accessibility_label` — optional removal label; defaults to "Remove
472///   {label}".
473/// - `accessibility_label` — optional accessibility label.
474/// - `accessibility_description` — optional accessibility description.
475///
476/// ## Examples
477///
478/// ```
479/// # use tessera_ui::tessera;
480/// # #[tessera]
481/// # fn component() {
482/// use tessera_components::{chip::chip, image::ImageData};
483/// use tessera_ui::{Callback, remember};
484/// # use tessera_components::theme::{MaterialTheme, material_theme};
485///
486/// # material_theme()
487/// #     .theme(|| MaterialTheme::default())
488/// #     .child(|| {
489/// let selected = remember(|| false);
490/// let toggle = Callback::new(move || selected.set(!selected.get()));
491/// toggle.call();
492/// assert!(selected.get());
493/// let avatar = ImageData::from_rgba(vec![255, 128, 0, 255], 1, 1);
494/// chip()
495///     .input("Alex")
496///     .avatar(avatar)
497///     .selected(selected.get())
498///     .on_click_shared(toggle);
499/// #     });
500/// # }
501/// # tessera_ui::testing::layout_test(component).run();
502/// ```
503#[tessera]
504pub fn chip(
505    variant: Option<ChipVariant>,
506    style: Option<ChipStyle>,
507    #[prop(into)] label: Option<String>,
508    #[prop(skip_setter)] leading_icon: Option<Painter>,
509    #[prop(skip_setter)] trailing_icon: Option<Painter>,
510    #[prop(into)] avatar: Option<Painter>,
511    selected: Option<bool>,
512    enabled: Option<bool>,
513    modifier: Option<Modifier>,
514    colors: Option<ChipColors>,
515    border: Option<ChipBorder>,
516    shape: Option<Shape>,
517    shape_style: Option<ChipShapeStyle>,
518    elevation: Option<Dp>,
519    on_click: Option<Callback>,
520    on_remove: Option<Callback>,
521    #[prop(into)] remove_accessibility_label: Option<String>,
522    #[prop(into)] accessibility_label: Option<String>,
523    #[prop(into)] accessibility_description: Option<String>,
524) {
525    let label = label.unwrap_or_default();
526    let modifier = modifier.unwrap_or_default();
527    let mut args = ChipResolvedArgs {
528        variant: variant.unwrap_or_default(),
529        style: style.unwrap_or_default(),
530        label,
531        leading_icon,
532        avatar,
533        trailing_icon,
534        selected: selected.unwrap_or(false),
535        enabled: enabled.unwrap_or(true),
536        modifier,
537        colors,
538        border,
539        shape,
540        shape_style: shape_style.unwrap_or_default(),
541        elevation,
542        on_click,
543        on_remove,
544        remove_accessibility_label,
545        accessibility_label,
546        accessibility_description,
547    };
548    let on_remove = if args.variant == ChipVariant::Input {
549        args.on_remove
550    } else {
551        None
552    };
553    let separate_remove = on_remove.is_some() && args.on_click.is_some();
554    let remove_only = on_remove.is_some() && args.on_click.is_none();
555    if on_remove.is_some() && args.trailing_icon.is_none() {
556        args.trailing_icon = Some(filled::CLOSE_SVG.into());
557    }
558    if remove_only {
559        args.on_click = on_remove;
560    }
561    let theme = use_context::<MaterialTheme>()
562        .expect("MaterialTheme must be provided")
563        .get();
564    let typography = theme.typography;
565    let variant = args.variant;
566    let style = args.style;
567    let selectable = matches!(variant, ChipVariant::Filter | ChipVariant::Input) && !remove_only;
568    let selected = args.selected && selectable;
569    let custom_colors = args.colors.is_some();
570    let colors = args
571        .colors
572        .unwrap_or_else(|| ChipDefaults::colors(&theme.color_scheme, variant, style));
573    let custom_border = args.border.is_some();
574    let mut border = args
575        .border
576        .or_else(|| ChipDefaults::border(&theme.color_scheme, variant, style));
577    let interaction_state = remember(InteractionState::new);
578    if (!args.enabled || args.on_click.is_none())
579        && interaction_state.get() != InteractionState::default()
580    {
581        interaction_state.set(InteractionState::default());
582    }
583    let interaction = Interaction::from_state(interaction_state.get());
584    if !custom_border && args.enabled && interaction == Interaction::Focus {
585        if let Some(border) = &mut border {
586            border.color = if variant == ChipVariant::Assist {
587                theme.color_scheme.on_surface
588            } else {
589                theme.color_scheme.on_surface_variant
590            };
591        }
592    }
593    let target_elevation = if !args.enabled {
594        Dp::ZERO
595    } else if let Some(elevation) = args.elevation {
596        elevation
597    } else if interaction == Interaction::Drag {
598        Dp(8.0)
599    } else if style == ChipStyle::Elevated {
600        if interaction == Interaction::Hover {
601            Dp(3.0)
602        } else {
603            Dp(1.0)
604        }
605    } else if variant == ChipVariant::Filter && selected && interaction == Interaction::Hover {
606        Dp(1.0)
607    } else {
608        Dp::ZERO
609    };
610    let elevation = remember(|| Elevation::new(target_elevation));
611    let mut next = elevation.get();
612    next.update(
613        target_elevation,
614        interaction,
615        args.enabled,
616        current_frame_nanos(),
617    );
618    if next != elevation.get() {
619        elevation.set(next);
620    }
621    if next.is_animating() {
622        receive_frame_nanos(move |now| {
623            let mut next = elevation.get();
624            next.advance(now);
625            if next != elevation.get() {
626                elevation.set(next);
627            }
628            if next.is_animating() {
629                tessera_ui::FrameNanosControl::Continue
630            } else {
631                tessera_ui::FrameNanosControl::Stop
632            }
633        });
634    }
635    let shadow_elevation = elevation.get().value();
636    let target_shape = args.shape.unwrap_or_else(|| {
637        if !selectable || args.shape_style == ChipShapeStyle::Standard {
638            theme.shapes.small
639        } else if interaction == Interaction::Press {
640            theme.shapes.small
641        } else if selected {
642            Shape::CAPSULE
643        } else {
644            theme.shapes.medium
645        }
646    });
647    let shape_motion = remember(|| ShapeMotion::new(target_shape));
648    let mut next = shape_motion.get();
649    next.retarget(
650        target_shape,
651        args.enabled
652            && args.shape.is_none()
653            && selectable
654            && args.shape_style == ChipShapeStyle::Expressive,
655        current_frame_nanos(),
656    );
657    if next != shape_motion.get() {
658        shape_motion.set(next);
659    }
660    if next.is_animating() {
661        receive_frame_nanos(move |now| {
662            let mut next = shape_motion.get();
663            next.advance(now);
664            if next != shape_motion.get() {
665                shape_motion.set(next);
666            }
667            if next.is_animating() {
668                tessera_ui::FrameNanosControl::Continue
669            } else {
670                tessera_ui::FrameNanosControl::Stop
671            }
672        });
673    }
674    let shape = shape_motion.get().shape();
675    let avatar = if variant == ChipVariant::Input {
676        args.avatar
677    } else {
678        None
679    };
680    let padding = chip_padding(
681        variant,
682        avatar.is_some(),
683        args.leading_icon.is_some(),
684        args.trailing_icon.is_some(),
685    );
686
687    let container_color = colors.container_color(args.enabled, selected);
688    let label_color = colors.label_color(args.enabled, selected);
689    let leading_icon_color = if !custom_colors
690        && args.enabled
691        && variant == ChipVariant::Input
692        && matches!(
693            interaction,
694            Interaction::Hover | Interaction::Focus | Interaction::Press
695        ) {
696        theme.color_scheme.primary
697    } else {
698        colors.leading_icon_color(args.enabled, selected)
699    };
700    let trailing_icon_color = colors.trailing_icon_color(args.enabled, selected);
701
702    let border_style = border.and_then(|border| border.resolve(args.enabled, selected));
703    let surface_style = match border_style {
704        Some((border_width, border_color)) => SurfaceStyle::FilledOutlined {
705            fill_color: container_color,
706            border_color,
707            border_width,
708        },
709        None => SurfaceStyle::Filled {
710            color: container_color,
711        },
712    };
713
714    let label = args.label;
715    let remove_label = args
716        .remove_accessibility_label
717        .unwrap_or_else(|| format!("Remove {label}"));
718    let removal_icon = args.trailing_icon.clone();
719    let accessibility_label = args.accessibility_label.or_else(|| {
720        if remove_only {
721            Some(remove_label.clone())
722        } else {
723            (!label.is_empty()).then(|| label.clone())
724        }
725    });
726
727    let modifier = args
728        .modifier
729        .default_min_size(
730            if separate_remove { Dp(88.0) } else { Dp::ZERO },
731            ChipDefaults::HEIGHT,
732        )
733        .minimum_interactive_component_size();
734    let has_on_click = args.on_click.is_some();
735    let leading_icon = args.leading_icon;
736    let trailing_icon = args.trailing_icon;
737    let has_label = !label.is_empty();
738    let avatar_alpha = if args.enabled {
739        1.0
740    } else {
741        MaterialAlpha::DISABLED_CONTENT
742    };
743
744    layout()
745        .policy(ChipRemovalLayout {
746            separate_remove,
747            trailing_padding: padding.right,
748        })
749        .child(move || {
750            let avatar = avatar.clone();
751            let leading_icon = leading_icon.clone();
752            let trailing_icon = trailing_icon.clone();
753            let label = label.clone();
754            surface()
755                .modifier(modifier.clone())
756                .style(surface_style.clone())
757                .shape(shape)
758                .content_alignment(Alignment::Center)
759                .content_color(label_color)
760                .enabled(args.enabled)
761                .ripple_color(label_color)
762                .elevation(shadow_elevation)
763                .interaction_state(interaction_state)
764                .on_click_optional(args.on_click)
765                .accessibility_role_optional((has_on_click || selectable).then_some(
766                    if selectable {
767                        Role::CheckBox
768                    } else {
769                        Role::Button
770                    },
771                ))
772                .accessibility_toggled_optional(selectable.then_some(if selected {
773                    Toggled::True
774                } else {
775                    Toggled::False
776                }))
777                .accessibility_focusable_optional(has_on_click.then_some(true))
778                .accessibility_label_optional(accessibility_label.clone())
779                .accessibility_description_optional(args.accessibility_description.clone())
780                .child(move || {
781                    let avatar = avatar.clone();
782                    let leading_icon = leading_icon.clone();
783                    let trailing_icon = trailing_icon.clone();
784                    let label = label.clone();
785                    provide_context(
786                        || typography.label_large,
787                        move || {
788                            let inherited = use_context::<TextStyle>().expect("chip text style");
789                            if inherited.get() != typography.label_large {
790                                inherited.set(typography.label_large);
791                            }
792                            layout()
793                                .modifier(Modifier::new().padding(padding))
794                                .child(move || {
795                                    let avatar = avatar.clone();
796                                    let leading_icon = leading_icon.clone();
797                                    let trailing_icon = trailing_icon.clone();
798                                    let label = label.clone();
799                                    row()
800                                        .cross_axis_alignment(CrossAxisAlignment::Center)
801                                        .children(move || {
802                                            let spacing = ChipDefaults::ELEMENT_SPACING;
803                                            let mut item_count = 0;
804
805                                            if let Some(avatar) = avatar.clone() {
806                                                item_count += 1;
807                                                chip_avatar().painter(avatar).alpha(avatar_alpha);
808                                            } else if let Some(icon_content) = leading_icon.clone()
809                                            {
810                                                item_count += 1;
811                                                icon()
812                                                    .painter(icon_content.clone())
813                                                    .size(ChipDefaults::ICON_SIZE)
814                                                    .tint(leading_icon_color);
815                                            }
816
817                                            if has_label {
818                                                if item_count > 0 {
819                                                    spacer()
820                                                        .modifier(Modifier::new().width(spacing));
821                                                }
822                                                item_count += 1;
823                                                text(label.clone());
824                                            }
825
826                                            if let Some(icon_content) = trailing_icon.clone() {
827                                                if item_count > 0 {
828                                                    spacer()
829                                                        .modifier(Modifier::new().width(spacing));
830                                                }
831                                                if separate_remove {
832                                                    spacer().modifier(Modifier::new().size(
833                                                        ChipDefaults::ICON_SIZE,
834                                                        ChipDefaults::ICON_SIZE,
835                                                    ));
836                                                } else {
837                                                    icon()
838                                                        .painter(icon_content.clone())
839                                                        .size(ChipDefaults::ICON_SIZE)
840                                                        .tint(trailing_icon_color);
841                                                }
842                                            }
843                                        });
844                                });
845                        },
846                    );
847                });
848            if separate_remove {
849                chip_remove_action(remove_label.clone(), on_remove.expect("input chip removal"))
850                    .icon_optional(removal_icon.clone())
851                    .enabled(args.enabled)
852                    .color(trailing_icon_color);
853            }
854        });
855}
856
857#[derive(Clone, Copy, PartialEq)]
858struct ChipRemovalLayout {
859    separate_remove: bool,
860    trailing_padding: Dp,
861}
862
863impl tessera_ui::LayoutModifierNode for ChipRemovalLayout {
864    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
865        let children = scope.children();
866        let constraints = scope.parent_constraint().without_min();
867        let body = children[0];
868        let size = body.measure(scope, &constraints)?.size();
869        let mut result = LayoutResult::new(size);
870        result.place_child(body, PxPosition::ZERO);
871        if self.separate_remove {
872            let remove = children[1];
873            let remove_size = remove.measure(scope, &constraints)?.size();
874            let center = size.width
875                - Px::from(self.trailing_padding)
876                - Px::from(ChipDefaults::ICON_SIZE) / 2;
877            result.place_child(
878                remove,
879                PxPosition::new(
880                    center - remove_size.width / 2,
881                    (size.height - remove_size.height) / 2,
882                ),
883            );
884        }
885        Ok(result)
886    }
887}
888
889#[tessera]
890fn chip_remove_action(
891    icon: Option<Painter>,
892    enabled: Option<bool>,
893    color: Option<Color>,
894    label: String,
895    on_remove: Callback,
896) {
897    let enabled = enabled.unwrap_or(true);
898    surface()
899        .modifier(Modifier::new().size(Dp(48.0), Dp(48.0)))
900        .shape(Shape::Ellipse)
901        .style(Color::TRANSPARENT.into())
902        .content_alignment(Alignment::Center)
903        .enabled(enabled)
904        .accessibility_role(Role::Button)
905        .accessibility_label(label)
906        .accessibility_focusable(enabled)
907        .ripple_color(color.unwrap_or(Color::BLACK))
908        .on_click_shared(on_remove)
909        .child(move || {
910            if let Some(icon) = icon.clone() {
911                crate::icon::icon()
912                    .painter(icon)
913                    .size(ChipDefaults::ICON_SIZE)
914                    .tint_optional(color);
915            }
916        });
917}
918
919#[tessera]
920fn chip_avatar(painter: Option<Painter>, alpha: Option<f32>) {
921    let Some(painter) = painter.and_then(Painter::resolve) else {
922        crate::spacer::spacer()
923            .modifier(Modifier::new().size(ChipDefaults::AVATAR_SIZE, ChipDefaults::AVATAR_SIZE));
924        return;
925    };
926    let (width, height) = match &painter {
927        Painter::Source(_) => unreachable!("painter is resolved"),
928        Painter::Raster(data) => (f64::from(data.width), f64::from(data.height)),
929        Painter::Vector(data) => (
930            f64::from(data.viewport_width),
931            f64::from(data.viewport_height),
932        ),
933    };
934    let shortest = width.min(height);
935    let scale = if shortest.is_finite() && shortest > 0.0 {
936        ChipDefaults::AVATAR_SIZE.0 / shortest
937    } else {
938        1.0
939    };
940    let width = Dp(width * scale);
941    let height = Dp(height * scale);
942    boxed()
943        .modifier(
944            Modifier::new()
945                .size(ChipDefaults::AVATAR_SIZE, ChipDefaults::AVATAR_SIZE)
946                .clip_shape(Shape::Ellipse)
947                .alpha(alpha.unwrap_or(1.0)),
948        )
949        .alignment(Alignment::Center)
950        .children(move || {
951            image()
952                .painter(painter.clone())
953                .modifier(Modifier::new().required_size(width, height));
954        });
955}
956
957fn chip_padding(
958    variant: ChipVariant,
959    has_avatar: bool,
960    has_leading_icon: bool,
961    has_trailing_icon: bool,
962) -> Padding {
963    let (start, end) = match variant {
964        ChipVariant::Input => {
965            let start = if has_leading_icon && !has_avatar {
966                ChipDefaults::INPUT_ICON_PADDING
967            } else {
968                ChipDefaults::INPUT_EDGE_PADDING
969            };
970            let end = if has_trailing_icon {
971                ChipDefaults::INPUT_ICON_PADDING
972            } else {
973                ChipDefaults::INPUT_EDGE_PADDING
974            };
975            (start, end)
976        }
977        _ => (
978            ChipDefaults::HORIZONTAL_PADDING,
979            ChipDefaults::HORIZONTAL_PADDING,
980        ),
981    };
982    // Missing icons still reserve the label-to-edge spacing.
983    let start = Dp(start.0
984        + if has_leading_icon || has_avatar {
985            0.0
986        } else {
987            ChipDefaults::ELEMENT_SPACING.0
988        });
989    let end = Dp(end.0
990        + if has_trailing_icon {
991            0.0
992        } else {
993            ChipDefaults::ELEMENT_SPACING.0
994        });
995    Padding::new(start, Dp::ZERO, end, Dp::ZERO)
996}
997
998#[cfg(test)]
999mod tests;
1000
1001#[cfg(test)]
1002mod appearance_tests;
1003
1004#[cfg(test)]
1005mod avatar_tests;
1006
1007#[cfg(test)]
1008mod shape_tests;
1009
1010#[cfg(all(test, not(target_family = "wasm")))]
1011mod remove_tests;