Skip to main content

dioxus_ui_system/molecules/
sonner.rs

1//! Sonner molecule component
2//!
3//! Modern toast notifications with rich styling, swipe to dismiss, and progress bars.
4
5use crate::styles::Style;
6use crate::theme::tokens::Color;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9use std::time::Duration;
10
11/// Toast position on screen
12#[derive(Default, Clone, PartialEq, Debug)]
13pub enum ToastPosition {
14    #[default]
15    BottomRight,
16    BottomCenter,
17    BottomLeft,
18    TopRight,
19    TopCenter,
20    TopLeft,
21}
22
23/// Sonner toast variant for different purposes
24#[derive(Default, Clone, PartialEq, Debug)]
25pub enum SonnerVariant {
26    #[default]
27    Default,
28    Success,
29    Error,
30    Warning,
31    Info,
32    Loading,
33}
34
35/// Individual toast data
36#[derive(Clone, PartialEq, Debug)]
37pub struct Toast {
38    pub id: String,
39    pub title: String,
40    pub description: Option<String>,
41    pub variant: SonnerVariant,
42    pub duration: Duration,
43    pub action: Option<ToastAction>,
44}
45
46/// Toast action button
47#[derive(Clone, PartialEq, Debug)]
48pub struct ToastAction {
49    pub label: String,
50    pub on_click: EventHandler<()>,
51}
52
53/// Sonner (toast) properties
54#[derive(Props, Clone, PartialEq)]
55pub struct SonnerProps {
56    /// Array of active toasts
57    pub toasts: Vec<Toast>,
58    /// Position on screen
59    #[props(default)]
60    pub position: ToastPosition,
61    /// Rich colors for variants
62    #[props(default = true)]
63    pub rich_colors: bool,
64    /// Show close button
65    #[props(default = true)]
66    pub close_button: bool,
67    /// Custom inline styles
68    #[props(default)]
69    pub style: Option<String>,
70    /// Callback when toast is dismissed
71    #[props(default)]
72    pub on_dismiss: Option<EventHandler<String>>,
73}
74
75/// Sonner toast notification container
76#[component]
77pub fn Sonner(props: SonnerProps) -> Element {
78    let _theme = use_theme();
79    let position = props.position.clone();
80
81    let container_style = use_style(move |_t| {
82        let position_css = match position {
83            ToastPosition::BottomRight => "bottom: 16px; right: 16px;",
84            ToastPosition::BottomCenter => "bottom: 16px; left: 50%; transform: translateX(-50%);",
85            ToastPosition::BottomLeft => "bottom: 16px; left: 16px;",
86            ToastPosition::TopRight => "top: 16px; right: 16px;",
87            ToastPosition::TopCenter => "top: 16px; left: 50%; transform: translateX(-50%);",
88            ToastPosition::TopLeft => "top: 16px; left: 16px;",
89        };
90
91        Style::new()
92            .fixed()
93            .z_index(9999)
94            .flex()
95            .flex_col()
96            .custom(position_css)
97            .custom("gap: 8px;")
98            .max_w_px(400)
99            .pointer_events_none()
100            .build()
101    });
102
103    rsx! {
104        div {
105            style: "{container_style} {props.style.clone().unwrap_or_default()}",
106
107            for toast in props.toasts.iter() {
108                ToastItem {
109                    key: "{toast.id}",
110                    toast: toast.clone(),
111                    rich_colors: props.rich_colors,
112                    close_button: props.close_button,
113                    on_dismiss: props.on_dismiss.clone(),
114                }
115            }
116        }
117    }
118}
119
120#[derive(Props, Clone, PartialEq)]
121struct ToastItemProps {
122    toast: Toast,
123    rich_colors: bool,
124    close_button: bool,
125    on_dismiss: Option<EventHandler<String>>,
126}
127
128#[component]
129fn ToastItem(props: ToastItemProps) -> Element {
130    let theme = use_theme();
131    let progress = use_signal(|| 100.0);
132    let toast_id = props.toast.id.clone();
133
134    // Read theme colors
135    let theme_fg = use_memo(move || theme.tokens.read().colors.foreground.clone());
136    let theme_muted = use_memo(move || theme.tokens.read().colors.muted.clone());
137    let theme_primary = use_memo(move || theme.tokens.read().colors.primary.clone());
138    let theme_border = use_memo(move || theme.tokens.read().colors.border.clone());
139
140    let variant_colors = if props.rich_colors {
141        match props.toast.variant {
142            SonnerVariant::Success => (Color::new(34, 197, 94), "✓"),
143            SonnerVariant::Error => (Color::new(239, 68, 68), "✕"),
144            SonnerVariant::Warning => (Color::new(245, 158, 11), "!"),
145            SonnerVariant::Info => (Color::new(59, 130, 246), "i"),
146            SonnerVariant::Loading => (Color::new(100, 116, 139), "◌"),
147            SonnerVariant::Default => (theme_fg(), ""),
148        }
149    } else {
150        (theme_fg(), "")
151    };
152
153    let toast_style = use_style(move |t| {
154        Style::new()
155            .pointer_events_auto()
156            .bg(&t.colors.background)
157            .rounded(&t.radius, "lg")
158            .shadow(&t.shadows.lg)
159            .p(&t.spacing, "md")
160            .min_w_px(300)
161            .relative()
162            .overflow_hidden()
163            .build()
164    });
165
166    let border_color =
167        if props.rich_colors && !matches!(props.toast.variant, SonnerVariant::Default) {
168            variant_colors.0.to_rgba()
169        } else {
170            theme_border().to_rgba()
171        };
172
173    let icon_color = variant_colors.0.clone();
174    let icon_style = use_style(move |t| {
175        Style::new()
176            .flex()
177            .items_center()
178            .justify_center()
179            .w_px(20)
180            .h_px(20)
181            .rounded(&t.radius, "full")
182            .text_color(&icon_color)
183            .font_size(12)
184            .font_weight(600)
185            .flex_shrink(0)
186            .build()
187    });
188
189    let title_style = use_style(|t| {
190        Style::new()
191            .font_size(14)
192            .font_weight(600)
193            .text_color(&t.colors.foreground)
194            .build()
195    });
196
197    let desc_style = use_style(|t| {
198        Style::new()
199            .font_size(13)
200            .text_color(&t.colors.muted)
201            .mt(&t.spacing, "xs")
202            .build()
203    });
204
205    let variant_color = variant_colors.0.clone();
206    let is_rich = props.rich_colors;
207    let variant = props.toast.variant.clone();
208    let progress_style = use_style(move |t| {
209        let bg_color = if is_rich && !matches!(variant, SonnerVariant::Default) {
210            variant_color.clone()
211        } else {
212            t.colors.primary.clone()
213        };
214
215        Style::new()
216            .absolute()
217            .bottom("0")
218            .left("0")
219            .h_px(3)
220            .bg(&bg_color)
221            .transition("width 0.1s linear")
222            .build()
223    });
224
225    let handle_dismiss = move |_| {
226        if let Some(on_dismiss) = props.on_dismiss.clone() {
227            on_dismiss.call(toast_id.clone());
228        }
229    };
230
231    let (_, icon_char) = &variant_colors;
232
233    rsx! {
234        div {
235            style: "{toast_style} border: 1px solid {border_color};",
236
237            div {
238                style: "display: flex; gap: 12px; align-items: flex-start;",
239
240                if !icon_char.is_empty() {
241                    span { style: "{icon_style}", "{icon_char}" }
242                }
243
244                div { style: "flex: 1; min-width: 0;",
245                    div { style: "{title_style}", "{props.toast.title}" }
246
247                    if let Some(desc) = props.toast.description.clone() {
248                        div { style: "{desc_style}", "{desc}" }
249                    }
250
251                    if let Some(action) = props.toast.action.clone() {
252                        button {
253                            style: "margin-top: 8px; padding: 4px 12px; font-size: 12px; font-weight: 500; color: {theme_primary().to_rgba()}; background: transparent; border: 1px solid {theme_border().to_rgba()}; border-radius: 4px; cursor: pointer;",
254                            onclick: move |_| action.on_click.call(()),
255                            "{action.label}"
256                        }
257                    }
258                }
259
260                if props.close_button {
261                    button {
262                        style: "padding: 4px; background: transparent; border: none; color: {theme_muted().to_rgba()}; cursor: pointer; font-size: 14px; line-height: 1;",
263                        onclick: handle_dismiss,
264                        "✕"
265                    }
266                }
267            }
268
269            div {
270                style: "{progress_style} width: {progress()}%;",
271            }
272        }
273    }
274}
275
276/// Hook for using Sonner/toast
277pub fn use_sonner() -> UseSonner {
278    let toasts = use_signal(Vec::new);
279
280    UseSonner { toasts }
281}
282
283/// Sonner hook return type
284#[derive(Clone, Copy)]
285pub struct UseSonner {
286    toasts: Signal<Vec<Toast>>,
287}
288
289impl UseSonner {
290    /// Show a toast
291    pub fn toast(&mut self, title: impl Into<String>) -> String {
292        let id = generate_id();
293        let toast = Toast {
294            id: id.clone(),
295            title: title.into(),
296            description: None,
297            variant: SonnerVariant::Default,
298            duration: Duration::from_secs(5),
299            action: None,
300        };
301        self.toasts.write().push(toast);
302        id
303    }
304
305    /// Show success toast
306    pub fn success(&mut self, title: impl Into<String>) -> String {
307        let id = generate_id();
308        let toast = Toast {
309            id: id.clone(),
310            title: title.into(),
311            description: None,
312            variant: SonnerVariant::Success,
313            duration: Duration::from_secs(5),
314            action: None,
315        };
316        self.toasts.write().push(toast);
317        id
318    }
319
320    /// Show error toast
321    pub fn error(&mut self, title: impl Into<String>) -> String {
322        let id = generate_id();
323        let toast = Toast {
324            id: id.clone(),
325            title: title.into(),
326            description: None,
327            variant: SonnerVariant::Error,
328            duration: Duration::from_secs(5),
329            action: None,
330        };
331        self.toasts.write().push(toast);
332        id
333    }
334
335    /// Dismiss a toast
336    pub fn dismiss(&mut self, id: &str) {
337        self.toasts.write().retain(|t| t.id != id);
338    }
339
340    /// Get the toasts signal for reactive reading
341    pub fn toasts_signal(&self) -> Signal<Vec<Toast>> {
342        self.toasts
343    }
344
345    /// Get current toasts (for one-time reads)
346    pub fn toasts(&self) -> Vec<Toast> {
347        self.toasts.read().clone()
348    }
349}
350
351fn generate_id() -> String {
352    use std::sync::atomic::{AtomicU64, Ordering};
353    static COUNTER: AtomicU64 = AtomicU64::new(0);
354    format!("toast-{}", COUNTER.fetch_add(1, Ordering::SeqCst))
355}