ui: Introduce Banner component (#27853)

Danilo Leal created

This PR adds a new, generic `Banner` component so that we can
potentially replace the multiple, isolated implementations of it
throughout some places of the app.

<img
src="https://github.com/user-attachments/assets/a268f745-1747-48e6-9461-2732eb7c0be4"
width="750"/>

Release Notes:

- N/A

Change summary

crates/ui/src/components.rs        |   2 
crates/ui/src/components/banner.rs | 192 ++++++++++++++++++++++++++++++++
2 files changed, 194 insertions(+)

Detailed changes

crates/ui/src/components.rs 🔗

@@ -1,4 +1,5 @@
 mod avatar;
+mod banner;
 mod button;
 mod content_group;
 mod context_menu;
@@ -37,6 +38,7 @@ mod tooltip;
 mod stories;
 
 pub use avatar::*;
+pub use banner::*;
 pub use button::*;
 pub use content_group::*;
 pub use context_menu::*;

crates/ui/src/components/banner.rs 🔗

@@ -0,0 +1,192 @@
+use crate::prelude::*;
+use gpui::{AnyElement, IntoElement, ParentElement, Styled};
+
+/// Severity levels that determine the style of the banner.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Severity {
+    Info,
+    Success,
+    Warning,
+    Error,
+}
+
+/// Banners provide informative and brief messages without interrupting the user.
+/// This component offers four severity levels that can be used depending on the message.
+///
+/// # Usage Example
+///
+/// ```
+/// use ui::{Banner};
+///
+///    Banner::new()
+///     .severity(Severity::Info)
+///     .children(Label::new("This is an informational message"))
+///     .action_slot(
+///         Button::new("learn-more", "Learn More")
+///             .icon(IconName::ArrowUpRight)
+///             .icon_size(IconSize::XSmall)
+///             .icon_position(IconPosition::End),
+///     )
+/// ```
+#[derive(IntoElement, IntoComponent)]
+#[component(scope = "Notification")]
+pub struct Banner {
+    severity: Severity,
+    children: Option<AnyElement>,
+    icon: Option<(IconName, Option<Color>)>,
+    action_slot: Option<AnyElement>,
+}
+
+impl Banner {
+    /// Creates a new `Banner` component with default styling.
+    pub fn new() -> Self {
+        Self {
+            severity: Severity::Info,
+            children: None,
+            icon: None,
+            action_slot: None,
+        }
+    }
+
+    /// Sets the severity of the banner.
+    pub fn severity(mut self, severity: Severity) -> Self {
+        self.severity = severity;
+        self
+    }
+
+    /// Sets an icon to display in the banner with an optional color.
+    pub fn icon(mut self, icon: IconName, color: Option<impl Into<Color>>) -> Self {
+        self.icon = Some((icon, color.map(|c| c.into())));
+        self
+    }
+
+    /// A slot for actions, such as CTA or dismissal buttons.
+    pub fn action_slot(mut self, element: impl IntoElement) -> Self {
+        self.action_slot = Some(element.into_any_element());
+        self
+    }
+
+    /// A general container for the banner's main content.
+    pub fn children(mut self, element: impl IntoElement) -> Self {
+        self.children = Some(element.into_any_element());
+        self
+    }
+}
+
+impl RenderOnce for Banner {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let base = h_flex()
+            .py_0p5()
+            .rounded_sm()
+            .flex_wrap()
+            .justify_between()
+            .border_1();
+
+        let (icon, icon_color, bg_color, border_color) = match self.severity {
+            Severity::Info => (
+                IconName::Info,
+                Color::Muted,
+                cx.theme().status().info_background.opacity(0.5),
+                cx.theme().colors().border_variant,
+            ),
+            Severity::Success => (
+                IconName::Check,
+                Color::Success,
+                cx.theme().status().success.opacity(0.1),
+                cx.theme().status().success.opacity(0.2),
+            ),
+            Severity::Warning => (
+                IconName::Warning,
+                Color::Warning,
+                cx.theme().status().warning_background.opacity(0.5),
+                cx.theme().status().warning_border.opacity(0.4),
+            ),
+            Severity::Error => (
+                IconName::XCircle,
+                Color::Error,
+                cx.theme().status().error.opacity(0.1),
+                cx.theme().status().error.opacity(0.2),
+            ),
+        };
+
+        let mut container = base.bg(bg_color).border_color(border_color);
+
+        let mut content_area = h_flex().id("content_area").gap_1p5().overflow_x_scroll();
+
+        if self.icon.is_none() {
+            content_area =
+                content_area.child(Icon::new(icon).size(IconSize::XSmall).color(icon_color));
+        }
+
+        if let Some(children) = self.children {
+            content_area = content_area.child(children);
+        }
+
+        if let Some(action_slot) = self.action_slot {
+            container = container
+                .pl_2()
+                .pr_0p5()
+                .gap_2()
+                .child(content_area)
+                .child(action_slot);
+        } else {
+            container = container.px_2().child(content_area);
+        }
+
+        container
+    }
+}
+
+impl ComponentPreview for Banner {
+    fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
+        let severity_examples = vec![
+            single_example(
+                "Default",
+                Banner::new()
+                    .children(Label::new("This is a default banner with no customization"))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Info",
+                Banner::new()
+                    .severity(Severity::Info)
+                    .children(Label::new("This is an informational message"))
+                    .action_slot(
+                        Button::new("learn-more", "Learn More")
+                            .icon(IconName::ArrowUpRight)
+                            .icon_size(IconSize::XSmall)
+                            .icon_position(IconPosition::End),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Success",
+                Banner::new()
+                    .severity(Severity::Success)
+                    .children(Label::new("Operation completed successfully"))
+                    .action_slot(Button::new("dismiss", "Dismiss"))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Warning",
+                Banner::new()
+                    .severity(Severity::Warning)
+                    .children(Label::new("Your settings file uses deprecated settings"))
+                    .action_slot(Button::new("update", "Update Settings"))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Error",
+                Banner::new()
+                    .severity(Severity::Error)
+                    .children(Label::new("Connection error: unable to connect to server"))
+                    .action_slot(Button::new("reconnect", "Retry"))
+                    .into_any_element(),
+            ),
+        ];
+
+        example_group(severity_examples)
+            .vertical()
+            .into_any_element()
+    }
+}