radix_leptos_primitives/components/
toast.rs

1use leptos::*;
2use leptos::prelude::*;
3use leptos::context::Provider;
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicUsize, Ordering};
6
7/// Toast variant for different notification types
8#[derive(Clone, Copy, PartialEq, Eq, Hash)]
9pub enum ToastVariant {
10    Default,
11    Success,
12    Error,
13    Warning,
14    Info,
15}
16
17/// Toast position on screen
18#[derive(Clone, Copy, PartialEq, Eq, Hash)]
19pub enum ToastPosition {
20    TopLeft,
21    TopRight,
22    TopCenter,
23    BottomLeft,
24    BottomRight,
25    BottomCenter,
26}
27
28/// Toast size variant
29#[derive(Clone, Copy, PartialEq, Eq, Hash)]
30pub enum ToastSize {
31    Small,
32    Medium,
33    Large,
34}
35
36/// Individual toast item
37#[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>, // milliseconds, None for persistent
44    pub created_at: std::time::Instant,
45}
46
47/// Toast context for managing toast state across components
48#[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
56/// Generate a unique ID for toast items
57fn 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
63/// Merge CSS classes with proper spacing
64fn merge_classes(classes: &[&str]) -> String {
65    classes.iter().filter(|&&c| !c.is_empty()).map(|&s| s).collect::<Vec<&str>>().join(" ")
66}
67
68/// Create a new toast item
69pub 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/// Root component for Toast system - provides context and state management
86#[component]
87pub fn ToastRoot(
88    /// Default position for toasts
89    #[prop(optional, default = ToastPosition::TopRight)]
90    position: ToastPosition,
91    /// Maximum number of toasts to show at once
92    #[prop(optional, default = 5)]
93    max_toasts: usize,
94    /// Child components
95    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/// Toast provider component that renders the toast container
114#[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    // Auto-dismiss toasts with duration
123    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 &current_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/// Individual toast item component
188#[component]
189pub fn ToastItemComponent(
190    /// The toast item to display
191    toast: ToastItem,
192    /// Dismiss handler
193    #[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
269/// Hook to use toast functionality
270pub 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        // Remove oldest toast if at max capacity
278        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/// Toast action component for triggering toasts
295#[component]
296pub fn ToastAction(
297    /// Toast item to show
298    toast: ToastItem,
299    /// Whether the action is disabled
300    #[prop(optional, default = false)]
301    disabled: bool,
302    /// CSS classes
303    #[prop(optional)]
304    class: Option<String>,
305    /// Click handler
306    #[prop(optional)]
307    on_click: Option<Callback<web_sys::MouseEvent>>,
308    /// Child content
309    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/// Toast viewport component for rendering toasts
338#[component]
339pub fn ToastViewport() -> impl IntoView {
340    view! {
341        <ToastProvider />
342    }
343}