dioxus_ui_system/molecules/
sonner.rs1use crate::styles::Style;
6use crate::theme::tokens::Color;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9use std::time::Duration;
10
11#[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#[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#[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#[derive(Clone, PartialEq, Debug)]
48pub struct ToastAction {
49 pub label: String,
50 pub on_click: EventHandler<()>,
51}
52
53#[derive(Props, Clone, PartialEq)]
55pub struct SonnerProps {
56 pub toasts: Vec<Toast>,
58 #[props(default)]
60 pub position: ToastPosition,
61 #[props(default = true)]
63 pub rich_colors: bool,
64 #[props(default = true)]
66 pub close_button: bool,
67 #[props(default)]
69 pub style: Option<String>,
70 #[props(default)]
72 pub on_dismiss: Option<EventHandler<String>>,
73}
74
75#[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 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
276pub fn use_sonner() -> UseSonner {
278 let toasts = use_signal(Vec::new);
279
280 UseSonner { toasts }
281}
282
283#[derive(Clone, Copy)]
285pub struct UseSonner {
286 toasts: Signal<Vec<Toast>>,
287}
288
289impl UseSonner {
290 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 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 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 pub fn dismiss(&mut self, id: &str) {
337 self.toasts.write().retain(|t| t.id != id);
338 }
339
340 pub fn toasts_signal(&self) -> Signal<Vec<Toast>> {
342 self.toasts
343 }
344
345 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}