Skip to main content

herogpui_components/
card.rs

1//! Card — port of `@heroui/card`.
2
3use gpui::{
4    prelude::*, px, AnyElement, App, IntoElement, ParentElement, Pixels, RenderOnce, Window,
5};
6use herogpui_theme::ActiveTheme;
7
8/// Card prominence level. Every fill level paints its surface shade and
9/// carries the surface shadow; `transparent` paints nothing.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum CardVariant {
12    /// No background — for cards with custom painting.
13    Transparent,
14    /// `bg-surface`
15    #[default]
16    Default,
17    /// `bg-surface-secondary`
18    Secondary,
19    /// `bg-surface-tertiary`
20    Tertiary,
21}
22
23impl CardVariant {
24    /// Every card variant, in declaration order.
25    pub const ALL: [CardVariant; 4] = [
26        CardVariant::Transparent,
27        CardVariant::Default,
28        CardVariant::Secondary,
29        CardVariant::Tertiary,
30    ];
31
32    /// A human-readable label for this variant.
33    pub fn label(self) -> &'static str {
34        match self {
35            CardVariant::Transparent => "Transparent",
36            CardVariant::Default => "Default",
37            CardVariant::Secondary => "Secondary",
38            CardVariant::Tertiary => "Tertiary",
39        }
40    }
41}
42
43/// HeroUI Card container.
44#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
45#[derive(IntoElement)]
46pub struct Card {
47    variant: CardVariant,
48    width: Option<Pixels>,
49    children: Vec<AnyElement>,
50    /// The corner radius, in place of `min(32px, --radius-3xl)`.
51    radius: Option<Pixels>,
52    /// The `sx` slot, refined over the root style at the end of render.
53    sx: Option<Box<gpui::StyleRefinement>>,
54}
55
56impl Card {
57    /// Creates an empty card.
58    pub fn new() -> Self {
59        Self {
60            variant: CardVariant::Default,
61            width: None,
62            children: Vec::new(),
63            radius: None,
64            sx: None,
65        }
66    }
67
68    /// Sets the card variant.
69    pub fn variant(mut self, variant: CardVariant) -> Self {
70        self.variant = variant;
71        self
72    }
73
74    /// Fixed card width.
75    pub fn w(mut self, v: impl Into<Pixels>) -> Self {
76        self.width = Some(v.into());
77        self
78    }
79
80    /// The corner radius, in place of `min(32px, --radius-3xl)`. Not a v3
81    /// prop; the removed v2 `radius` prop is prohibited and this is a
82    /// per-component repository extension.
83    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
84        self.radius = Some(radius.into());
85        self
86    }
87
88    /// The one slot for caller-owned low-level styling: GPUI's styling methods
89    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
90    /// applied to the card's root element after every value the variant and
91    /// the active theme chose, so they win.
92    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
93        crate::util::refine_sx(&mut self.sx, style);
94        self
95    }
96}
97
98impl Default for Card {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl ParentElement for Card {
105    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
106        self.children.extend(elements);
107    }
108}
109
110impl RenderOnce for Card {
111    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
112        let colors = cx.colors();
113        let layout = cx.layout();
114        // `.card` is `flex flex-col gap-3 p-4`: the card is the padded box and
115        // its parts (`__header`, `__content`, `__footer`) carry none of their
116        // own, which is why this used to double the inset on every section.
117        let mut el = gpui::div()
118            .flex()
119            .flex_col()
120            .gap(px(12.))
121            .p(px(16.))
122            .rounded(
123                self.radius
124                    .unwrap_or_else(|| crate::util::container_radius(cx)),
125            )
126            .children(self.children);
127        // Upstream `.card` is `overflow-visible`: no clipping call here.
128
129        if let Some(w) = self.width {
130            el = el.w(w);
131        }
132
133        // `card--default`/`--secondary`/`--tertiary` set the background;
134        // `card--transparent` is `border-none bg-transparent shadow-none`, so
135        // it paints nothing and keeps the full content box for its parts.
136        el = match self.variant {
137            CardVariant::Transparent => el,
138            CardVariant::Default => el.bg(colors.surface.background),
139            CardVariant::Secondary => el.bg(colors.surface_secondary),
140            CardVariant::Tertiary => el.bg(colors.surface_tertiary),
141        };
142        if self.variant != CardVariant::Transparent && !layout.surface_shadow.is_empty() {
143            el = el.shadow(layout.surface_shadow.clone());
144        }
145
146        el = crate::util::apply_sx(el, &self.sx);
147        el
148    }
149}
150
151/// Card header section (`CardHeader`).
152#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
153#[derive(IntoElement)]
154pub struct CardHeader {
155    children: Vec<AnyElement>,
156    /// The `sx` slot, refined over the root style at the end of render.
157    sx: Option<Box<gpui::StyleRefinement>>,
158}
159
160impl CardHeader {
161    /// Creates an empty instance.
162    pub fn new() -> Self {
163        Self {
164            children: Vec::new(),
165            sx: None,
166        }
167    }
168
169    /// The one slot for caller-owned low-level styling: GPUI's styling methods
170    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
171    /// applied to the header's root element after every value the card and the
172    /// active theme chose, so they win.
173    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
174        crate::util::refine_sx(&mut self.sx, style);
175        self
176    }
177}
178
179impl Default for CardHeader {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl ParentElement for CardHeader {
186    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
187        self.children.extend(elements);
188    }
189}
190
191impl RenderOnce for CardHeader {
192    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
193        // `.card__header` is `flex flex-col` and nothing else: the title's
194        // text style belongs to `CardTitle` and the description's to
195        // `CardDescription`.
196        let el = gpui::div().flex().flex_col().children(self.children);
197        crate::util::apply_sx(el, &self.sx)
198    }
199}
200
201/// Card title (`CardTitle`, upstream `.card__title`).
202#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
203#[derive(IntoElement)]
204pub struct CardTitle {
205    children: Vec<AnyElement>,
206    /// The `sx` slot, refined over the root style at the end of render.
207    sx: Option<Box<gpui::StyleRefinement>>,
208}
209
210impl CardTitle {
211    /// Creates an empty instance.
212    pub fn new() -> Self {
213        Self {
214            children: Vec::new(),
215            sx: None,
216        }
217    }
218
219    /// The one slot for caller-owned low-level styling: GPUI's styling methods
220    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
221    /// applied to the title's root element after every value the card and the
222    /// active theme chose, so they win.
223    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
224        crate::util::refine_sx(&mut self.sx, style);
225        self
226    }
227}
228
229impl Default for CardTitle {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235impl ParentElement for CardTitle {
236    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
237        self.children.extend(elements);
238    }
239}
240
241impl RenderOnce for CardTitle {
242    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
243        // `.card__title` is `text-sm leading-6 font-medium text-foreground`.
244        let colors = cx.colors();
245        let el = gpui::div()
246            .text_size(px(14.))
247            .line_height(px(24.))
248            .font_weight(gpui::FontWeight::MEDIUM)
249            .text_color(colors.foreground)
250            .children(self.children);
251        crate::util::apply_sx(el, &self.sx)
252    }
253}
254
255/// Card description (`CardDescription`, upstream `.card__description`).
256#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
257#[derive(IntoElement)]
258pub struct CardDescription {
259    children: Vec<AnyElement>,
260    /// The `sx` slot, refined over the root style at the end of render.
261    sx: Option<Box<gpui::StyleRefinement>>,
262}
263
264impl CardDescription {
265    /// Creates an empty instance.
266    pub fn new() -> Self {
267        Self {
268            children: Vec::new(),
269            sx: None,
270        }
271    }
272
273    /// The one slot for caller-owned low-level styling: GPUI's styling methods
274    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
275    /// applied to the description's root element after every value the card and
276    /// the active theme chose, so they win.
277    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
278        crate::util::refine_sx(&mut self.sx, style);
279        self
280    }
281}
282
283impl Default for CardDescription {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289impl ParentElement for CardDescription {
290    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
291        self.children.extend(elements);
292    }
293}
294
295impl RenderOnce for CardDescription {
296    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
297        // `.card__description` is `text-sm leading-5 text-muted`.
298        let colors = cx.colors();
299        let el = gpui::div()
300            .text_size(px(14.))
301            .line_height(px(20.))
302            .text_color(colors.muted)
303            .children(self.children);
304        crate::util::apply_sx(el, &self.sx)
305    }
306}
307
308/// Content section (`CardContent`, upstream `.card__content`).
309#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
310#[derive(IntoElement)]
311pub struct CardContent {
312    children: Vec<AnyElement>,
313    /// The `sx` slot, refined over the root style at the end of render.
314    sx: Option<Box<gpui::StyleRefinement>>,
315}
316
317impl CardContent {
318    /// Creates an empty instance.
319    pub fn new() -> Self {
320        Self {
321            children: Vec::new(),
322            sx: None,
323        }
324    }
325
326    /// The one slot for caller-owned low-level styling: GPUI's styling methods
327    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
328    /// applied to the content's root element after every value the card and the
329    /// active theme chose, so they win.
330    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
331        crate::util::refine_sx(&mut self.sx, style);
332        self
333    }
334}
335
336impl Default for CardContent {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342impl ParentElement for CardContent {
343    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
344        self.children.extend(elements);
345    }
346}
347
348impl RenderOnce for CardContent {
349    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
350        // `.card__content` is `flex flex-1 flex-col gap-1`. The upstream
351        // `flex-1` is dropped: the pinned-geometry test in `tests/card_deep.rs`
352        // measures the card as an auto-height column hugging its parts, which
353        // flex-1 regresses.
354        let el = gpui::div()
355            .flex()
356            .flex_col()
357            .gap(px(4.))
358            .children(self.children);
359        crate::util::apply_sx(el, &self.sx)
360    }
361}
362
363/// Card footer section (`CardFooter`).
364#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
365#[derive(IntoElement)]
366pub struct CardFooter {
367    children: Vec<AnyElement>,
368    /// The `sx` slot, refined over the root style at the end of render.
369    sx: Option<Box<gpui::StyleRefinement>>,
370}
371
372impl CardFooter {
373    /// Creates an empty instance.
374    pub fn new() -> Self {
375        Self {
376            children: Vec::new(),
377            sx: None,
378        }
379    }
380
381    /// The one slot for caller-owned low-level styling: GPUI's styling methods
382    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
383    /// applied to the footer's root element after every value the card and the
384    /// active theme chose, so they win.
385    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
386        crate::util::refine_sx(&mut self.sx, style);
387        self
388    }
389}
390
391impl Default for CardFooter {
392    fn default() -> Self {
393        Self::new()
394    }
395}
396
397impl ParentElement for CardFooter {
398    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
399        self.children.extend(elements);
400    }
401}
402
403impl RenderOnce for CardFooter {
404    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
405        // `.card__footer` is `flex flex-row items-center` -- no padding, gap,
406        // or text size of its own; the card's gap separates the parts and the
407        // caller composes the row's contents.
408        let el = gpui::div().flex().items_center().children(self.children);
409        crate::util::apply_sx(el, &self.sx)
410    }
411}
412
413crate::util::impl_component_styled!(
414    Card,
415    CardHeader,
416    CardTitle,
417    CardDescription,
418    CardContent,
419    CardFooter
420);