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