alert: Add banner mode and on_close to Alert. (#893)

## Break Changes

- The `Alert::new` method change to 2 args, first is `id`.

```diff
- fn new(message: impl Into<Text>) -> Self
+ fn new(id: impl Into<ElementId>, message: impl Into<Text>) -> Self
```

<img width="1120" alt="image"
src="https://github.com/user-attachments/assets/214df737-6129-4a53-8f3b-86e83bc8082b"
/>
This commit is contained in:
Jason Lee 2025-05-23 19:20:13 +08:00 committed by GitHub
parent a1e2160083
commit f539817d9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 219 additions and 57 deletions

View file

@ -13,6 +13,7 @@ use crate::section;
pub struct AlertStory { pub struct AlertStory {
size: Size, size: Size,
banner_visible: bool,
focus_handle: gpui::FocusHandle, focus_handle: gpui::FocusHandle,
} }
@ -20,6 +21,7 @@ impl AlertStory {
fn new(_: &mut Window, cx: &mut Context<Self>) -> Self { fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
Self { Self {
size: Size::default(), size: Size::default(),
banner_visible: true,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
} }
} }
@ -62,6 +64,20 @@ impl Render for AlertStory {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex() v_flex()
.gap_4() .gap_4()
.child(
Alert::warning(
"banner-1",
"This is a banner alert, it will take the full width of the container.",
)
.banner()
.on_close(cx.listener(|this, _, _, cx| {
this.banner_visible = !this.banner_visible;
cx.notify();
}))
.visible(self.banner_visible)
.with_size(self.size)
.icon(IconName::Bell),
)
.child( .child(
ButtonGroup::new("toggle-size") ButtonGroup::new("toggle-size")
.outline() .outline()
@ -99,14 +115,18 @@ impl Render for AlertStory {
) )
.child( .child(
section("Info").max_w_md().child( section("Info").max_w_md().child(
Alert::info("This is an info alert.") Alert::info("info1", "This is an info alert.")
.with_size(self.size) .with_size(self.size)
.title("Info message"), .title("Info message")
.on_close(cx.listener(|_, _, _, _| {
println!("Info alert closed");
})),
), ),
) )
.child( .child(
section("Success with Title").max_w_md().child( section("Success with Title").max_w_md().child(
Alert::success( Alert::success(
"success-1",
"You have successfully submitted your form.\n\ "You have successfully submitted your form.\n\
Thank you for your submission!", Thank you for your submission!",
) )
@ -117,6 +137,7 @@ impl Render for AlertStory {
.child( .child(
section("Warning").max_w_md().child( section("Warning").max_w_md().child(
Alert::warning( Alert::warning(
"warning-1",
"This is a warning alert with icon and title.\n\ "This is a warning alert with icon and title.\n\
This is second line of text to test is the line-height is correct.", This is second line of text to test is the line-height is correct.",
) )
@ -126,6 +147,7 @@ impl Render for AlertStory {
.child( .child(
section("Error").max_w_md().child( section("Error").max_w_md().child(
Alert::error( Alert::error(
"error-1",
"There was an error submitting your form.\n\ "There was an error submitting your form.\n\
Please try again later, if you still have issues, please contact support.", Please try again later, if you still have issues, please contact support.",
) )
@ -135,7 +157,7 @@ impl Render for AlertStory {
) )
.child( .child(
section("Custom Icon").max_w_md().child( section("Custom Icon").max_w_md().child(
Alert::info("Custom icon with info alert.") Alert::info("other-1", "Custom icon with info alert.")
.title("Custom Icon") .title("Custom Icon")
.with_size(self.size) .with_size(self.size)
.icon(IconName::Bell), .icon(IconName::Bell),

View file

@ -1,6 +1,9 @@
use std::rc::Rc;
use gpui::{ use gpui::{
div, prelude::FluentBuilder as _, px, relative, App, Div, Hsla, IntoElement, div, prelude::FluentBuilder as _, px, relative, App, ClickEvent, Div, ElementId, Empty, Hsla,
ParentElement as _, RenderOnce, SharedString, Styled, Window, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement, Styled, Window,
}; };
use crate::{h_flex, text::Text, ActiveTheme as _, Icon, IconName, Sizable, Size, StyledExt}; use crate::{h_flex, text::Text, ActiveTheme as _, Icon, IconName, Sizable, Size, StyledExt};
@ -37,51 +40,57 @@ impl AlertVariant {
/// Alert used to display a message to the user. /// Alert used to display a message to the user.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Alert { pub struct Alert {
base: Div, base: Stateful<Div>,
variant: AlertVariant, variant: AlertVariant,
icon: Option<Icon>, icon: Option<Icon>,
title: Option<SharedString>, title: Option<SharedString>,
message: Text, message: Text,
size: Size, size: Size,
banner: bool,
on_close: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
visible: bool,
} }
impl Alert { impl Alert {
/// Create a new alert with the given message. /// Create a new alert with the given message.
fn new(message: impl Into<Text>) -> Self { fn new(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self { Self {
base: div(), base: div().id(id),
variant: AlertVariant::default(), variant: AlertVariant::default(),
icon: None, icon: None,
title: None, title: None,
message: message.into(), message: message.into(),
size: Size::default(), size: Size::default(),
banner: false,
visible: true,
on_close: None,
} }
} }
/// Create a new info [`AlertVariant::Info`] with the given message. /// Create a new info [`AlertVariant::Info`] with the given message.
pub fn info(message: impl Into<Text>) -> Self { pub fn info(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(message) Self::new(id, message)
.with_variant(AlertVariant::Info) .with_variant(AlertVariant::Info)
.icon(IconName::Info) .icon(IconName::Info)
} }
/// Create a new [`AlertVariant::Success`] alert with the given message. /// Create a new [`AlertVariant::Success`] alert with the given message.
pub fn success(message: impl Into<Text>) -> Self { pub fn success(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(message) Self::new(id, message)
.with_variant(AlertVariant::Success) .with_variant(AlertVariant::Success)
.icon(IconName::CircleCheck) .icon(IconName::CircleCheck)
} }
/// Create a new [`AlertVariant::Warning`] alert with the given message. /// Create a new [`AlertVariant::Warning`] alert with the given message.
pub fn warning(message: impl Into<Text>) -> Self { pub fn warning(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(message) Self::new(id, message)
.with_variant(AlertVariant::Warning) .with_variant(AlertVariant::Warning)
.icon(IconName::TriangleAlert) .icon(IconName::TriangleAlert)
} }
/// Create a new [`AlertVariant::Error`] alert with the given message. /// Create a new [`AlertVariant::Error`] alert with the given message.
pub fn error(message: impl Into<Text>) -> Self { pub fn error(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(message) Self::new(id, message)
.with_variant(AlertVariant::Error) .with_variant(AlertVariant::Error)
.icon(IconName::CircleX) .icon(IconName::CircleX)
} }
@ -103,6 +112,30 @@ impl Alert {
self.title = Some(title.into()); self.title = Some(title.into());
self self
} }
/// Set alert as banner style.
///
/// The `banner` style will make the alert take the full width of the container and not border and radius.
/// This mode will not display `title`.
pub fn banner(mut self) -> Self {
self.banner = true;
self
}
/// Set alert as closable, true will show Close icon.
pub fn on_close(
mut self,
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Some(Rc::new(on_close));
self
}
/// Set the visibility of the alert.
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
} }
impl Sizable for Alert { impl Sizable for Alert {
@ -120,6 +153,10 @@ impl Styled for Alert {
impl RenderOnce for Alert { impl RenderOnce for Alert {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if !self.visible {
return Empty.into_any_element();
}
let (radius, padding_x, padding_y, gap, line_height, icon_mt) = match self.size { let (radius, padding_x, padding_y, gap, line_height, icon_mt) = match self.size {
Size::XSmall => (cx.theme().radius, px(12.), px(6.), px(6.), 1.2, px(2.5)), Size::XSmall => (cx.theme().radius, px(12.), px(6.), px(6.), 1.2, px(2.5)),
Size::Small => (cx.theme().radius, px(12.), px(8.), px(6.), 1.2, px(1.5)), Size::Small => (cx.theme().radius, px(12.), px(8.), px(6.), 1.2, px(1.5)),
@ -143,47 +180,81 @@ impl RenderOnce for Alert {
let color = self.variant.color(cx); let color = self.variant.color(cx);
self.base.flex_1().child( self.base
h_flex() .flex_1()
.w_full() .when(self.banner, |this| this.w_full())
.rounded(radius) .child(
.border_1() h_flex()
.border_color(color) .w_full()
.bg(color.opacity(0.1)) .items_center()
.text_color(self.variant.fg(cx)) .when(!self.banner, |this| {
.px(padding_x) this.rounded(radius)
.py(padding_y) .border_1()
.gap(gap) .border_color(color)
.overflow_hidden() .items_start()
.items_start() })
.map(|this| match self.size { .bg(color.opacity(0.06))
Size::Large => this.text_base(), .text_color(self.variant.fg(cx))
_ => this.text_sm(), .px(padding_x)
}) .py(padding_y)
.line_height(relative(line_height)) .gap(gap)
.child( .overflow_hidden()
div().mt(icon_mt).child( .justify_between()
self.icon .map(|this| match self.size {
.unwrap_or(IconName::Info.into()) Size::Large => this.text_base(),
.with_size(self.size) _ => this.text_sm(),
.flex_shrink_0(), })
), .line_height(relative(line_height))
) .child(
.child( h_flex()
div() .items_start()
.overflow_hidden() .gap(gap)
.when_some(self.title, |this, title| { .child(
this.child( div().mt(icon_mt).child(
div() self.icon
.w_full() .unwrap_or(IconName::Info.into())
.truncate() .with_size(self.size)
.mb_1() .flex_shrink_0(),
.font_semibold() ),
.child(title),
) )
}) .child(
.child(div().overflow_hidden().child(self.message)), div()
), .overflow_hidden()
) .when(!self.banner, |this| {
this.when_some(self.title, |this, title| {
this.child(
div()
.w_full()
.truncate()
.mb_1()
.font_semibold()
.child(title),
)
})
})
.child(div().overflow_hidden().child(self.message)),
),
)
.when_some(self.on_close, |this, on_close| {
this.child(
div()
.id("close")
.p_0p5()
.rounded(cx.theme().radius)
.hover(|this| this.bg(color.opacity(0.1)))
.active(|this| this.bg(color.opacity(0.2)))
.on_click(move |ev, window, cx| {
on_close(ev, window, cx);
})
.child(
Icon::new(IconName::Close)
.text_color(cx.theme().foreground)
.with_size(self.size.max(Size::Medium))
.flex_shrink_0(),
),
)
}),
)
.into_any_element()
} }
} }

View file

@ -180,6 +180,16 @@ pub enum Size {
} }
impl Size { impl Size {
fn as_f32(&self) -> f32 {
match self {
Size::Size(val) => val.0,
Size::XSmall => 0.,
Size::Small => 1.,
Size::Medium => 2.,
Size::Large => 3.,
}
}
/// Returns the height for table row. /// Returns the height for table row.
#[inline] #[inline]
pub fn table_row_height(&self) -> Pixels { pub fn table_row_height(&self) -> Pixels {
@ -243,6 +253,32 @@ impl Size {
Size::Size(val) => Size::Size(*val * 1.2), Size::Size(val) => Size::Size(*val * 1.2),
} }
} }
/// Return the max size between two sizes.
///
/// e.g. `Size::XSmall.max(Size::Small)` will return `Size::XSmall`.
pub fn max(&self, other: Self) -> Self {
match (self, other) {
(Size::Size(a), Size::Size(b)) => Size::Size(px(a.0.min(b.0))),
(Size::Size(a), _) => Size::Size(*a),
(_, Size::Size(b)) => Size::Size(b),
(a, b) if a.as_f32() < b.as_f32() => *a,
_ => other,
}
}
/// Return the min size between two sizes.
///
/// e.g. `Size::XSmall.min(Size::Small)` will return `Size::Small`.
pub fn min(&self, other: Self) -> Self {
match (self, other) {
(Size::Size(a), Size::Size(b)) => Size::Size(px(a.0.max(b.0))),
(Size::Size(a), _) => Size::Size(*a),
(_, Size::Size(b)) => Size::Size(b),
(a, b) if a.as_f32() > b.as_f32() => *a,
_ => other,
}
}
} }
impl From<Pixels> for Size { impl From<Pixels> for Size {
@ -520,3 +556,36 @@ pub trait Collapsible {
fn collapsed(self, collapsed: bool) -> Self; fn collapsed(self, collapsed: bool) -> Self;
fn is_collapsed(&self) -> bool; fn is_collapsed(&self) -> bool;
} }
#[cfg(test)]
mod tests {
use gpui::px;
use crate::Size;
#[test]
fn test_size_max_min() {
assert_eq!(Size::Small.min(Size::XSmall), Size::Small);
assert_eq!(Size::XSmall.min(Size::Small), Size::Small);
assert_eq!(Size::Small.min(Size::Medium), Size::Medium);
assert_eq!(Size::Medium.min(Size::Large), Size::Large);
assert_eq!(Size::Large.min(Size::Small), Size::Large);
assert_eq!(
Size::Size(px(10.)).min(Size::Size(px(20.))),
Size::Size(px(20.))
);
// Min
assert_eq!(Size::Small.max(Size::XSmall), Size::XSmall);
assert_eq!(Size::XSmall.max(Size::Small), Size::XSmall);
assert_eq!(Size::Small.max(Size::Medium), Size::Small);
assert_eq!(Size::Medium.max(Size::Large), Size::Medium);
assert_eq!(Size::Large.max(Size::Small), Size::Small);
assert_eq!(
Size::Size(px(10.)).max(Size::Size(px(20.))),
Size::Size(px(10.))
);
}
}