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}