radix_leptos_primitives/components/
toast.rs1use leptos::*;
2use leptos::prelude::*;
3use leptos::context::Provider;
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicUsize, Ordering};
6
7#[derive(Clone, Copy, PartialEq, Eq, Hash)]
9pub enum ToastVariant {
10 Default,
11 Success,
12 Error,
13 Warning,
14 Info,
15}
16
17#[derive(Clone, Copy, PartialEq, Eq, Hash)]
19pub enum ToastPosition {
20 TopLeft,
21 TopRight,
22 TopCenter,
23 BottomLeft,
24 BottomRight,
25 BottomCenter,
26}
27
28#[derive(Clone, Copy, PartialEq, Eq, Hash)]
30pub enum ToastSize {
31 Small,
32 Medium,
33 Large,
34}
35
36#[derive(Clone)]
38pub struct ToastItem {
39 pub id: String,
40 pub title: String,
41 pub description: Option<String>,
42 pub variant: ToastVariant,
43 pub duration: Option<u32>, pub created_at: std::time::Instant,
45}
46
47#[derive(Clone)]
49struct ToastContext {
50 toasts: ReadSignal<HashMap<String, ToastItem>>,
51 set_toasts: WriteSignal<HashMap<String, ToastItem>>,
52 position: ToastPosition,
53 max_toasts: usize,
54}
55
56fn generate_toast_id() -> String {
58 static COUNTER: AtomicUsize = AtomicUsize::new(0);
59 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
60 format!("toast-{}", id)
61}
62
63fn merge_classes(classes: &[&str]) -> String {
65 classes.iter().filter(|&&c| !c.is_empty()).map(|&s| s).collect::<Vec<&str>>().join(" ")
66}
67
68pub fn create_toast(
70 title: impl Into<String>,
71 description: Option<impl Into<String>>,
72 variant: ToastVariant,
73 duration: Option<u32>,
74) -> ToastItem {
75 ToastItem {
76 id: generate_toast_id(),
77 title: title.into(),
78 description: description.map(|d| d.into()),
79 variant,
80 duration,
81 created_at: std::time::Instant::now(),
82 }
83}
84
85#[component]
87pub fn ToastRoot(
88 #[prop(optional, default = ToastPosition::TopRight)]
90 position: ToastPosition,
91 #[prop(optional, default = 5)]
93 max_toasts: usize,
94 children: Children,
96) -> impl IntoView {
97 let (toasts_signal, set_toasts_signal) = signal(HashMap::<String, ToastItem>::new());
98
99 let context = ToastContext {
100 toasts: toasts_signal,
101 set_toasts: set_toasts_signal,
102 position,
103 max_toasts,
104 };
105
106 view! {
107 <Provider value=context>
108 {children()}
109 </Provider>
110 }
111}
112
113#[component]
115pub fn ToastProvider() -> impl IntoView {
116 let context = use_context::<ToastContext>()
117 .expect("ToastProvider must be used within ToastRoot");
118
119 let toasts = move || context.toasts.get();
120 let position = move || context.position;
121
122 Effect::new(move |_| {
124 let current_toasts = toasts();
125 let mut updated_toasts = current_toasts.clone();
126 let mut to_remove = Vec::new();
127
128 for (id, toast) in ¤t_toasts {
129 if let Some(duration) = toast.duration {
130 if toast.created_at.elapsed().as_millis() > duration as u128 {
131 to_remove.push(id.clone());
132 }
133 }
134 }
135
136 for id in to_remove {
137 updated_toasts.remove(&id);
138 }
139
140 if updated_toasts.len() != current_toasts.len() {
141 context.set_toasts.set(updated_toasts);
142 }
143 });
144
145 let position_class = move || {
146 match position() {
147 ToastPosition::TopLeft => "radix-toast--position-top-left",
148 ToastPosition::TopRight => "radix-toast--position-top-right",
149 ToastPosition::TopCenter => "radix-toast--position-top-center",
150 ToastPosition::BottomLeft => "radix-toast--position-bottom-left",
151 ToastPosition::BottomRight => "radix-toast--position-bottom-right",
152 ToastPosition::BottomCenter => "radix-toast--position-bottom-center",
153 }
154 };
155
156 let toasts_vec = move || {
157 toasts().into_iter().collect::<Vec<_>>()
158 };
159
160 view! {
161 <div class=merge_classes(&["radix-toast-provider", &position_class()])>
162 <For
163 each=toasts_vec
164 key=|(id, _)| id.clone()
165 children=move |(id, toast)| {
166 let context_clone = context.clone();
167 let toast_clone = toast.clone();
168
169 let handle_dismiss = Callback::new(move |_: web_sys::MouseEvent| {
170 let mut current_toasts = context_clone.toasts.get();
171 current_toasts.remove(&id);
172 context_clone.set_toasts.set(current_toasts);
173 });
174
175 view! {
176 <ToastItemComponent
177 toast=toast_clone
178 on_dismiss=handle_dismiss
179 />
180 }
181 }
182 />
183 </div>
184 }
185}
186
187#[component]
189pub fn ToastItemComponent(
190 toast: ToastItem,
192 #[prop(optional)]
194 on_dismiss: Option<Callback<web_sys::MouseEvent>>,
195) -> impl IntoView {
196 let variant_class = move || {
197 match toast.variant {
198 ToastVariant::Default => "radix-toast--variant-default",
199 ToastVariant::Success => "radix-toast--variant-success",
200 ToastVariant::Error => "radix-toast--variant-error",
201 ToastVariant::Warning => "radix-toast--variant-warning",
202 ToastVariant::Info => "radix-toast--variant-info",
203 }
204 };
205
206 let variant_icon = move || {
207 match toast.variant {
208 ToastVariant::Default => "đĸ",
209 ToastVariant::Success => "â
",
210 ToastVariant::Error => "â",
211 ToastVariant::Warning => "â ī¸",
212 ToastVariant::Info => "âšī¸",
213 }
214 };
215
216 let handle_dismiss = move |e: web_sys::MouseEvent| {
217 if let Some(callback) = on_dismiss {
218 callback.run(e);
219 }
220 };
221
222 let title_clone = toast.title.clone();
223 let description_clone = toast.description.clone();
224 let icon_clone = variant_icon();
225
226 view! {
227 <div
228 class=merge_classes(&["radix-toast-item", &variant_class()])
229 role="alert"
230 aria-live="polite"
231 >
232 <div class="radix-toast-item-content">
233 <div class="radix-toast-item-icon">
234 {icon_clone}
235 </div>
236 <div class="radix-toast-item-body">
237 <div class="radix-toast-item-header">
238 <h4 class="radix-toast-item-title">
239 {title_clone}
240 </h4>
241 <button
242 class="radix-toast-item-close"
243 on:click=handle_dismiss
244 aria-label="Close notification"
245 >
246 "Ã"
247 </button>
248 </div>
249 {move || {
250 if let Some(desc) = &description_clone {
251 let desc_clone = desc.clone();
252 view! {
253 <p class="radix-toast-item-description">
254 {desc_clone}
255 </p>
256 }
257 } else {
258 view! {
259 <p class="radix-toast-item-description">{String::new()}</p>
260 }
261 }
262 }}
263 </div>
264 </div>
265 </div>
266 }
267}
268
269pub fn use_toast() -> impl Fn(ToastItem) + Clone {
271 let context = use_context::<ToastContext>()
272 .expect("use_toast must be used within ToastRoot");
273
274 move |toast: ToastItem| {
275 let mut current_toasts = context.toasts.get();
276
277 if current_toasts.len() >= context.max_toasts {
279 let oldest_id = current_toasts
280 .iter()
281 .min_by_key(|(_, t)| t.created_at)
282 .map(|(id, _)| id.clone());
283
284 if let Some(id) = oldest_id {
285 current_toasts.remove(&id);
286 }
287 }
288
289 current_toasts.insert(toast.id.clone(), toast);
290 context.set_toasts.set(current_toasts);
291 }
292}
293
294#[component]
296pub fn ToastAction(
297 toast: ToastItem,
299 #[prop(optional, default = false)]
301 disabled: bool,
302 #[prop(optional)]
304 class: Option<String>,
305 #[prop(optional)]
307 on_click: Option<Callback<web_sys::MouseEvent>>,
308 children: Children,
310) -> impl IntoView {
311 let show_toast = use_toast();
312 let toast_clone = toast.clone();
313
314 let handle_click = move |e: web_sys::MouseEvent| {
315 if !disabled {
316 show_toast(toast_clone.clone());
317 if let Some(callback) = on_click {
318 callback.run(e);
319 }
320 }
321 };
322
323 let class_value = class.unwrap_or_default();
324 let children_view = children();
325
326 view! {
327 <button
328 class=class_value
329 disabled=disabled
330 on:click=handle_click
331 >
332 {children_view}
333 </button>
334 }
335}
336
337#[component]
339pub fn ToastViewport() -> impl IntoView {
340 view! {
341 <ToastProvider />
342 }
343}