Skip to main content

herogpui_components/
alert.rs

1//! Alert — port of `@heroui/alert`.
2
3use gpui::{
4    px, AnyElement, App, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
5    SharedString, Styled, Window,
6};
7use herogpui_core::Color;
8use herogpui_theme::ActiveTheme;
9
10use crate::icons;
11
12/// HeroUI Alert.
13///
14/// v3.2.4's API table carries only `status`/`className`/`children`: the
15/// migration guide explicitly removes `isClosable`, `onClose` and
16/// `closeButtonProps`, so a close affordance is composed by the caller as an
17/// ordinary child (a `CloseButton`) instead of being built in.
18#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
19#[derive(IntoElement)]
20pub struct Alert {
21    title: SharedString,
22    description: Option<SharedString>,
23    color: Color,
24    /// `Alert.Indicator` children replace the status glyph while retaining
25    /// the pinned 24px indicator box and status-owned default fallback.
26    indicator: Option<AnyElement>,
27    /// Composed children — v3's "Additional content like buttons, close
28    /// button, etc.", appended after the content column.
29    children: Vec<AnyElement>,
30    /// The `sx` slot, refined over the root style at the end of render.
31    sx: Option<Box<gpui::StyleRefinement>>,
32    /// The corner radius, in place of the owning `control_radius` helper.
33    radius: Option<Pixels>,
34}
35
36impl Alert {
37    /// `status` — the v3 name for `color`; the values are the same
38    /// semantic roles.
39    pub fn status(mut self, status: Color) -> Self {
40        self.color = status;
41        self
42    }
43
44    /// Creates an alert with the given title.
45    pub fn new(title: impl Into<SharedString>) -> Self {
46        Self {
47            title: title.into(),
48            description: None,
49            color: Color::Default,
50            indicator: None,
51            children: Vec::new(),
52            sx: None,
53            radius: None,
54        }
55    }
56
57    /// Sets the description shown under the title.
58    pub fn description(mut self, d: impl Into<SharedString>) -> Self {
59        self.description = Some(d.into());
60        self
61    }
62
63    /// Replaces the default status glyph inside the indicator slot. The
64    /// caller owns the child content; the surrounding box keeps HeroUI's
65    /// 4px inset and fixed 16px glyph footprint.
66    pub fn indicator(mut self, content: impl IntoElement) -> Self {
67        self.indicator = Some(content.into_any_element());
68        self
69    }
70
71    /// The corner radius, in place of the owning `control_radius` helper. Not a
72    /// v3 prop; the removed v2 `radius` prop is prohibited and this is a
73    /// per-component repository extension.
74    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
75        self.radius = Some(radius.into());
76        self
77    }
78
79    /// The one slot for caller-owned low-level styling: GPUI's styling methods
80    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
81    /// applied to the alert's root element after every value the status and
82    /// the active theme chose, so they win.
83    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
84        crate::util::refine_sx(&mut self.sx, style);
85        self
86    }
87}
88
89impl ParentElement for Alert {
90    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
91        // End content; see the struct doc for the v3 composition rationale.
92        self.children.extend(elements);
93    }
94}
95
96impl RenderOnce for Alert {
97    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
98        let sem = cx.role(self.color);
99        let colors = cx.colors();
100        let layout = cx.layout();
101
102        // v3 dropped Alert's `variant`: the color role is the only axis.
103        // `.alert` is `bg-surface` for every status -- the role never paints
104        // the container.
105        let bg = colors.surface.background;
106        let fg = colors.foreground;
107        // `.alert--default` paints the title *and* the indicator
108        // `text-foreground`; every status paints `text-{role}-soft-foreground`.
109        let role_fg = if self.color == Color::Default {
110            colors.foreground
111        } else {
112            sem.soft_foreground(colors.foreground)
113        };
114
115        // `alert.tsx`'s `getDefaultIcon`: accent and the `default` fall-through
116        // both draw the Info glyph; success the circled check (the pinned
117        // `SuccessIcon`), warning the triangle, danger the circle-exclamation.
118        let glyph = match self.color {
119            Color::Default | Color::Accent => icons::INFO_CIRCLE,
120            Color::Success => icons::CHECK_CIRCLE,
121            Color::Warning => icons::WARNING_TRIANGLE,
122            Color::Danger => icons::CIRCLE_EXCLAMATION,
123        };
124
125        let radius = self
126            .radius
127            .unwrap_or_else(|| crate::util::control_radius(cx));
128        let mut alert = gpui::div()
129            .flex()
130            .items_start()
131            .justify_start()
132            .gap(px(16.))
133            .w_full()
134            .px(px(16.))
135            .py(px(12.))
136            .rounded(radius)
137            .bg(bg)
138            .text_color(fg)
139            .debug_selector(|| "alert-root".to_owned());
140        // `shadow-surface` is the surface elevation token; dark mode leaves
141        // the token empty, and GPUI 0.2.2 paints an empty shadow list as
142        // nothing, so the token is applied unconditionally.
143        alert = alert.shadow(layout.surface_shadow.clone());
144
145        let default_indicator_glyph = gpui::svg()
146            .size(px(16.))
147            .path(glyph)
148            .text_color(role_fg)
149            .flex_shrink_0();
150        let indicator_glyph = self
151            .indicator
152            .unwrap_or_else(|| default_indicator_glyph.into_any_element());
153        alert = alert.child(
154            // `.alert__indicator` is a `p-1` box around the 16px glyph, centered.
155            gpui::div()
156                .p(px(4.))
157                .flex()
158                .items_center()
159                .justify_center()
160                .flex_shrink_0()
161                .debug_selector(|| "alert-indicator".to_owned())
162                .child(indicator_glyph),
163        );
164
165        // `.alert__content` is the column that holds the title and the
166        // description, beside the indicator. It carries no gap: the pinned
167        // rule is only `flex h-full grow flex-col items-start`.
168        // `min_w_0` is what lets the description wrap. A flex item's automatic
169        // minimum size is its content's, and gpui measures a text child's
170        // minimum as the whole string, so `grow` alone pushed the column --
171        // and the copy -- straight out through the alert's right edge.
172        let mut text_col = gpui::div()
173            .flex()
174            .flex_col()
175            .items_start()
176            .flex_1()
177            .min_w_0();
178        text_col = text_col.child(
179            gpui::div()
180                .text_size(px(14.)) // `.alert__title` is `text-sm leading-6 font-medium`.
181                .line_height(px(24.))
182                .font_weight(gpui::FontWeight::MEDIUM)
183                .text_color(role_fg)
184                .debug_selector(|| "alert-title".to_owned())
185                .child(self.title.to_string()),
186        );
187        text_col = text_col.debug_selector(|| "alert-content".to_owned());
188        if let Some(desc) = self.description {
189            text_col = text_col.child(
190                gpui::div()
191                    // `.alert__description` is `text-sm`.
192                    .text_size(px(14.))
193                    // `text-sm`'s own line height is 20px.
194                    .line_height(px(20.))
195                    .text_color(colors.muted)
196                    .debug_selector(|| "alert-description".to_owned())
197                    .child(desc.to_string()),
198            );
199        }
200        alert = alert.child(text_col);
201
202        // Composed children go last; see the struct doc.
203        alert = alert.children(self.children);
204
205        alert = crate::util::apply_sx(alert, &self.sx);
206        alert
207    }
208}
209
210// The pinned `.alert` is `bg-surface` for every status: the role paints the
211// indicator and the title only, never the container. A soft wash looks
212// plausible on screen, so the check is mechanical.
213#[cfg(test)]
214mod painted_tokens {
215    fn implementation() -> &'static str {
216        include_str!("alert.rs")
217            .split("#[cfg(test)]")
218            .next()
219            .expect("the implementation section is always present")
220    }
221
222    #[test]
223    fn the_alert_container_is_always_surface() {
224        let source = implementation();
225        assert!(
226            source.contains("let bg = colors.surface.background;"),
227            "every status must paint `bg-surface` (pinned `.alert`)"
228        );
229        assert!(
230            !source.contains("sem.soft()"),
231            "no alert container may paint a role soft background"
232        );
233    }
234
235    #[test]
236    fn the_indicator_follows_the_status_soft_foreground() {
237        let source = implementation();
238        // The glyph's own `let` declaration is the structural unit: everything
239        // between `let indicator_glyph = gpui::svg()` and the statement's
240        // terminating `;` is the full builder chain that paints the glyph.
241        let chain = source
242            .split("let default_indicator_glyph = gpui::svg()")
243            .nth(1)
244            .expect("the indicator must paint the glyph as a declared svg element")
245            .split(';')
246            .next()
247            .expect("the declaration must terminate");
248        assert!(
249            chain.contains(".text_color(role_fg)"),
250            "the indicator must paint the same token as the title: \
251             `text-foreground` on default, `text-{{role}}-soft-foreground` \
252             otherwise (pinned `.alert__indicator`)"
253        );
254        assert!(
255            !chain.contains("colors.muted"),
256            "the default indicator is `text-foreground`, not the muted tone"
257        );
258    }
259
260    #[test]
261    fn custom_indicator_keeps_the_shared_indicator_box() {
262        let source = implementation();
263        assert!(source.contains("pub fn indicator(mut self, content: impl IntoElement)"));
264        assert!(source.contains(".indicator\n            .unwrap_or_else"));
265        assert!(source.contains(".p(px(4.))"));
266        assert!(source.contains(".size(px(16.))"));
267    }
268}
269
270crate::util::impl_component_styled!(Alert);