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

View file

@ -1,6 +1,9 @@
use std::rc::Rc;
use gpui::{
div, prelude::FluentBuilder as _, px, relative, App, Div, Hsla, IntoElement,
ParentElement as _, RenderOnce, SharedString, Styled, Window,
div, prelude::FluentBuilder as _, px, relative, App, ClickEvent, Div, ElementId, Empty, Hsla,
InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement, Styled, Window,
};
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.
#[derive(IntoElement)]
pub struct Alert {
base: Div,
base: Stateful<Div>,
variant: AlertVariant,
icon: Option<Icon>,
title: Option<SharedString>,
message: Text,
size: Size,
banner: bool,
on_close: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
visible: bool,
}
impl Alert {
/// 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 {
base: div(),
base: div().id(id),
variant: AlertVariant::default(),
icon: None,
title: None,
message: message.into(),
size: Size::default(),
banner: false,
visible: true,
on_close: None,
}
}
/// Create a new info [`AlertVariant::Info`] with the given message.
pub fn info(message: impl Into<Text>) -> Self {
Self::new(message)
pub fn info(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(id, message)
.with_variant(AlertVariant::Info)
.icon(IconName::Info)
}
/// Create a new [`AlertVariant::Success`] alert with the given message.
pub fn success(message: impl Into<Text>) -> Self {
Self::new(message)
pub fn success(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(id, message)
.with_variant(AlertVariant::Success)
.icon(IconName::CircleCheck)
}
/// Create a new [`AlertVariant::Warning`] alert with the given message.
pub fn warning(message: impl Into<Text>) -> Self {
Self::new(message)
pub fn warning(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(id, message)
.with_variant(AlertVariant::Warning)
.icon(IconName::TriangleAlert)
}
/// Create a new [`AlertVariant::Error`] alert with the given message.
pub fn error(message: impl Into<Text>) -> Self {
Self::new(message)
pub fn error(id: impl Into<ElementId>, message: impl Into<Text>) -> Self {
Self::new(id, message)
.with_variant(AlertVariant::Error)
.icon(IconName::CircleX)
}
@ -103,6 +112,30 @@ impl Alert {
self.title = Some(title.into());
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 {
@ -120,6 +153,10 @@ impl Styled for Alert {
impl RenderOnce for Alert {
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 {
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)),
@ -143,47 +180,81 @@ impl RenderOnce for Alert {
let color = self.variant.color(cx);
self.base.flex_1().child(
h_flex()
.w_full()
.rounded(radius)
.border_1()
.border_color(color)
.bg(color.opacity(0.1))
.text_color(self.variant.fg(cx))
.px(padding_x)
.py(padding_y)
.gap(gap)
.overflow_hidden()
.items_start()
.map(|this| match self.size {
Size::Large => this.text_base(),
_ => this.text_sm(),
})
.line_height(relative(line_height))
.child(
div().mt(icon_mt).child(
self.icon
.unwrap_or(IconName::Info.into())
.with_size(self.size)
.flex_shrink_0(),
),
)
.child(
div()
.overflow_hidden()
.when_some(self.title, |this, title| {
this.child(
div()
.w_full()
.truncate()
.mb_1()
.font_semibold()
.child(title),
self.base
.flex_1()
.when(self.banner, |this| this.w_full())
.child(
h_flex()
.w_full()
.items_center()
.when(!self.banner, |this| {
this.rounded(radius)
.border_1()
.border_color(color)
.items_start()
})
.bg(color.opacity(0.06))
.text_color(self.variant.fg(cx))
.px(padding_x)
.py(padding_y)
.gap(gap)
.overflow_hidden()
.justify_between()
.map(|this| match self.size {
Size::Large => this.text_base(),
_ => this.text_sm(),
})
.line_height(relative(line_height))
.child(
h_flex()
.items_start()
.gap(gap)
.child(
div().mt(icon_mt).child(
self.icon
.unwrap_or(IconName::Info.into())
.with_size(self.size)
.flex_shrink_0(),
),
)
})
.child(div().overflow_hidden().child(self.message)),
),
)
.child(
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 {
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.
#[inline]
pub fn table_row_height(&self) -> Pixels {
@ -243,6 +253,32 @@ impl Size {
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 {
@ -520,3 +556,36 @@ pub trait Collapsible {
fn collapsed(self, collapsed: bool) -> Self;
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.))
);
}
}