From 64ef3ab09d8b88e1d9ecdf81ff4caa33d2984b52 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Tue, 1 Apr 2025 14:36:38 -0300
Subject: [PATCH] ui: Introduce Banner component (#27853)
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.
Release Notes:
- N/A
---
crates/ui/src/components.rs | 2 +
crates/ui/src/components/banner.rs | 192 +++++++++++++++++++++++++++++
2 files changed, 194 insertions(+)
create mode 100644 crates/ui/src/components/banner.rs
diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs
index 2264ae85379ad8388b0fc93a355851a6749c8a5b..7bedd3e4d86821a891f398dffa0e2d52149a311a 100644
--- a/crates/ui/src/components.rs
+++ b/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::*;
diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2cf265882aade351dfe3f70a8c857eed1b6b26b2
--- /dev/null
+++ b/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,
+ icon: Option<(IconName, Option)>,
+ action_slot: Option,
+}
+
+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>) -> 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()
+ }
+}