Skip to main content

herogpui_components/
chip.rs

1//! Chip — port of `@heroui/chip`.
2
3use gpui::{
4    px, AnyElement, App, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
5    Styled, Window,
6};
7use herogpui_core::{Color, Size};
8use herogpui_theme::{ActiveTheme, ThemeColors};
9
10/// Chip visual style (`primary | secondary | tertiary | soft`).
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum ChipVariant {
13    /// Filled with the role color, labelled in the role foreground.
14    Primary,
15    /// The base chip: filled with `default`, labelled in the color class's
16    /// foreground.
17    #[default]
18    Secondary,
19    /// Transparent fill — `.chip--tertiary` only clears `--chip-bg`, and
20    /// chip.css declares no border for any chip.
21    Tertiary,
22    /// Filled with `--{role}-soft` (the default role mixes at 50%), labelled
23    /// in the soft foreground.
24    Soft,
25}
26
27impl ChipVariant {
28    pub const ALL: [ChipVariant; 4] = [
29        ChipVariant::Primary,
30        ChipVariant::Secondary,
31        ChipVariant::Tertiary,
32        ChipVariant::Soft,
33    ];
34
35    pub fn label(self) -> &'static str {
36        match self {
37            ChipVariant::Primary => "Primary",
38            ChipVariant::Secondary => "Secondary",
39            ChipVariant::Tertiary => "Tertiary",
40            ChipVariant::Soft => "Soft",
41        }
42    }
43}
44
45/// HeroUI Chip root (`Chip`, upstream `.chip`).
46///
47/// v3's `ChipRoot` renders its children verbatim — an icon, a dot, a
48/// [`ChipLabel`], a trailing element — in the order they are composed, and
49/// auto-wraps plain-text children in the label part. This port makes that
50/// wrap explicit: compose a [`ChipLabel`] where v3's basic usage relies on
51/// it.
52#[derive(IntoElement)]
53pub struct Chip {
54    variant: ChipVariant,
55    color: Color,
56    size: Size,
57    children: Vec<AnyElement>,
58    /// The label's font size; unset keeps the size-step's pair.
59    text_size: Option<Pixels>,
60    /// The corner radius, in place of the size-step's radius.
61    radius: Option<Pixels>,
62    /// The `sx` slot, refined over the root style at the end of render.
63    sx: Option<Box<gpui::StyleRefinement>>,
64}
65
66impl Chip {
67    pub fn new() -> Self {
68        Self {
69            variant: ChipVariant::default(),
70            color: Color::Default,
71            size: Size::Md,
72            children: Vec::new(),
73            text_size: None,
74            radius: None,
75            sx: None,
76        }
77    }
78
79    pub fn variant(mut self, variant: ChipVariant) -> Self {
80        self.variant = variant;
81        self
82    }
83
84    pub fn color(mut self, color: Color) -> Self {
85        self.color = color;
86        self
87    }
88
89    pub fn size(mut self, size: Size) -> Self {
90        self.size = size;
91        self
92    }
93
94    /// The label's font size; unset keeps the size-step's pair. A 12/14/16
95    /// size follows v3's pairing (16/20/24); other sizes keep the 20px
96    /// leading.
97    pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
98        self.text_size = Some(size.into());
99        self
100    }
101
102    /// The corner radius, in place of the size-step's radius. Not a v3 prop;
103    /// the removed v2 `radius` prop is prohibited and this is a
104    /// per-component repository extension.
105    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
106        self.radius = Some(radius.into());
107        self
108    }
109
110    /// The one slot for caller-owned low-level styling: GPUI's styling methods
111    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
112    /// applied to the chip's root element after every value the variant, the
113    /// color and the active theme chose, so they win.
114    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
115        self.sx = Some(crate::util::capture_sx(style));
116        self
117    }
118}
119
120impl Default for Chip {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl ParentElement for Chip {
127    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
128        self.children.extend(elements);
129    }
130}
131
132/// The chip's label part (`Chip.Label`, upstream `.chip__label`).
133///
134/// The `.chip__label` `px-0.5` lives here and nowhere else: a chip root's
135/// arbitrary icon or dot children take no label padding.
136#[derive(IntoElement)]
137pub struct ChipLabel {
138    children: Vec<AnyElement>,
139    /// The `sx` slot, refined over the root style at the end of render.
140    sx: Option<Box<gpui::StyleRefinement>>,
141}
142
143impl ChipLabel {
144    pub fn new() -> Self {
145        Self {
146            children: Vec::new(),
147            sx: None,
148        }
149    }
150
151    /// The one slot for caller-owned low-level styling: GPUI's styling methods
152    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
153    /// applied to the label's root element after every value the chip and the
154    /// active theme chose, so they win.
155    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
156        self.sx = Some(crate::util::capture_sx(style));
157        self
158    }
159}
160
161impl Default for ChipLabel {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl ParentElement for ChipLabel {
168    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
169        self.children.extend(elements);
170    }
171}
172
173/// Resolves one chip's paint pair against v3.2.4's `.chip` cascade: the base
174/// rule, the `.chip--{color}` classes, the variant rules, and the compound
175/// variant×color rules. `None` paints no background (tertiary's transparent
176/// fill); no chip carries a border.
177fn paint(colors: &ThemeColors, variant: ChipVariant, color: Color) -> (Option<Hsla>, Hsla) {
178    let role = colors.role(color.token());
179    let muted_foreground = || {
180        if color == Color::Default {
181            colors.default.foreground
182        } else {
183            role.soft_foreground(colors.foreground)
184        }
185    };
186    match variant {
187        ChipVariant::Primary => (Some(role.color), role.foreground),
188        ChipVariant::Secondary => (Some(colors.default.color), muted_foreground()),
189        ChipVariant::Tertiary => (None, muted_foreground()),
190        // `.chip--default.chip--soft` fills with `--default-soft`, a 50% mix —
191        // not the 15% the accent and status roles use. `RoleColor::soft()`
192        // carries that per-role weight itself.
193        ChipVariant::Soft => (Some(role.soft()), muted_foreground()),
194    }
195}
196
197impl RenderOnce for Chip {
198    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
199        let colors = cx.colors();
200        let (bg, fg) = paint(colors, self.variant, self.color);
201        let radius = self.radius.unwrap_or_else(|| crate::util::soft_radius(cx));
202
203        // `.chip` is `px-2 py-0.5 text-xs leading-5 font-medium`, `--sm` is
204        // `px-1 py-0 text-xs`, `--md` is `text-xs` and `--lg` is `px-3 py-1
205        // text-sm`. Compiled Tailwind 4 lowers `leading-5` to
206        // `--tw-leading: var(--leading-5)` and lowers every `text-*` utility
207        // to `line-height: var(--tw-leading, <its own pair>)`, so the size
208        // rules' restated `text-*` utilities consume the base's 20px line
209        // instead of resetting it: one line height at every size. Like a tag,
210        // a chip has no height of its own: it is padding around one line.
211        let (pad_x, pad_y, text, leading) = match self.size {
212            Size::Sm => (px(4.), px(0.), px(12.), px(20.)),
213            Size::Md => (px(8.), px(2.), px(12.), px(20.)),
214            Size::Lg => (px(12.), px(4.), px(14.), px(20.)),
215        };
216        let text = self.text_size.unwrap_or(text);
217        let leading = self
218            .text_size
219            .and_then(crate::util::leading_for)
220            .unwrap_or(leading);
221
222        let mut el = gpui::div()
223            .flex()
224            .debug_selector(|| "chip".to_owned())
225            .items_center()
226            .gap(px(2.))
227            .px(pad_x)
228            .py(pad_y)
229            .text_size(text)
230            .line_height(leading)
231            // `font-medium` on `.chip`. v3 declares no `whitespace-nowrap` or
232            // `overflow-hidden` on a chip, so a label constrained by its
233            // parent wraps exactly as upstream's would.
234            .font_weight(gpui::FontWeight::MEDIUM)
235            .rounded(radius)
236            .flex_shrink_0();
237
238        el = match bg {
239            Some(bg) => el.bg(bg),
240            None => el,
241        };
242        el = el.text_color(fg);
243
244        el = el.children(self.children);
245        el = crate::util::apply_sx(el, &self.sx);
246        el
247    }
248}
249
250impl RenderOnce for ChipLabel {
251    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
252        // `.chip__label` is `px-0.5`.
253        let el = gpui::div()
254            .debug_selector(|| "chip-label".to_owned())
255            .px(px(2.))
256            .children(self.children);
257        crate::util::apply_sx(el, &self.sx)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    /// The pure variant×color paint matrix of `chip.css`: the base rule,
266    /// the `.chip--{color}` foreground classes, the variant rules, and the
267    /// compound `.chip--{variant}.chip--{color}` cells, over both
268    /// appearances. The headless test window cannot sample a fill, so the
269    /// cascade is pinned here instead.
270    #[test]
271    fn paint_matrix_matches_the_chip_css_cascade() {
272        for colors in [ThemeColors::light(), ThemeColors::dark()] {
273            for color in Color::ALL {
274                let role = colors.role(color.token());
275                let muted_foreground = if color == Color::Default {
276                    colors.default.foreground
277                } else {
278                    role.soft_foreground(colors.foreground)
279                };
280
281                // `.chip--primary.chip--{color}` fills with the role and
282                // labels in the role foreground. Default has no compound rule,
283                // so the base `--chip-bg: var(--default)` holds and the label
284                // stays `currentColor` — which the port resolves to the
285                // theme's default foreground because GPUI has no ancestor
286                // color context.
287                assert_eq!(
288                    paint(&colors, ChipVariant::Primary, color),
289                    (Some(role.color), role.foreground),
290                    "primary×{color:?} must fill with the role and label in its foreground"
291                );
292
293                // Secondary keeps the base `--chip-bg: var(--default)`
294                // whatever the colour; the colour classes only relabel, to
295                // the soft foreground (`--default-foreground` for default).
296                assert_eq!(
297                    paint(&colors, ChipVariant::Secondary, color),
298                    (Some(colors.default.color), muted_foreground),
299                    "secondary×{color:?} must keep the default fill and relabel only"
300                );
301
302                // `.chip--tertiary` only clears `--chip-bg`; the label comes
303                // from the same colour classes as secondary's.
304                assert_eq!(
305                    paint(&colors, ChipVariant::Tertiary, color),
306                    (None, muted_foreground),
307                    "tertiary×{color:?} must paint no fill and keep the soft label"
308                );
309
310                // `.chip--{color}.chip--soft` fills with `--{color}-soft`,
311                // lighter than the role itself, and keeps the soft label.
312                assert_eq!(
313                    paint(&colors, ChipVariant::Soft, color),
314                    (Some(role.soft()), muted_foreground),
315                    "soft×{color:?} must fill with the soft mix and keep the soft label"
316                );
317                assert_ne!(
318                    role.soft(),
319                    role.color,
320                    "the soft fill of {color:?} must not equal the solid fill"
321                );
322            }
323        }
324
325        // The roles are distinct fills: no colour may borrow another's
326        // primary background.
327        let colors = ThemeColors::light();
328        for (a, b) in [
329            (Color::Default, Color::Accent),
330            (Color::Accent, Color::Success),
331            (Color::Success, Color::Warning),
332            (Color::Warning, Color::Danger),
333        ] {
334            assert_ne!(
335                colors.role(a.token()).color,
336                colors.role(b.token()).color,
337                "the {a:?} and {b:?} roles must not share a fill"
338            );
339        }
340    }
341}