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);