toast.rs
1 use crate::components::button::{Button, ButtonVariant}; 2 use dioxus::html::GlobalAttributesExtension; 3 use dioxus::prelude::*; 4 use dioxus_sdk_time::use_timeout; 5 use lucide_dioxus::{Check, Info, TriangleAlert, X}; 6 use std::time::Duration; 7 8 // Toast types for different visual styles 9 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 10 pub enum ToastType { 11 Success, 12 Error, 13 Warning, 14 Info, 15 } 16 17 impl ToastType { 18 fn icon_component(&self) -> Element { 19 match self { 20 ToastType::Success => rsx! { Check { class: "size-5" } }, 21 ToastType::Error => rsx! { X { class: "size-5" } }, 22 ToastType::Warning => rsx! { TriangleAlert { class: "size-5" } }, 23 ToastType::Info => rsx! { Info { class: "size-5" } }, 24 } 25 } 26 27 fn classes(&self) -> &'static str { 28 "border-border bg-popover text-foreground" 29 } 30 31 fn icon_classes(&self) -> &'static str { 32 match self { 33 ToastType::Success => "text-green-600 dark:text-green-400", 34 ToastType::Error => "text-red-600 dark:text-red-400", 35 ToastType::Warning => "text-yellow-600 dark:text-yellow-400", 36 ToastType::Info => "text-foreground", 37 } 38 } 39 } 40 41 // A single toast item 42 #[derive(Debug, Clone, PartialEq)] 43 pub struct ToastItem { 44 pub id: usize, 45 pub title: String, 46 pub description: Option<String>, 47 pub toast_type: ToastType, 48 pub duration: Option<Duration>, 49 pub permanent: bool, 50 pub visible: bool, 51 } 52 53 // Global signal for toast management 54 static TOASTS: GlobalSignal<Vec<ToastItem>> = Signal::global(Vec::new); 55 static NEXT_ID: GlobalSignal<usize> = Signal::global(|| 0); 56 57 // Toast provider props 58 #[derive(Props, Clone, PartialEq)] 59 pub struct ToastProviderProps { 60 #[props(default = Duration::from_secs(5))] 61 pub default_duration: Duration, 62 63 #[props(default = 10)] 64 pub max_toasts: usize, 65 66 pub children: Element, 67 } 68 69 // Toast provider component 70 #[component] 71 pub fn ToastProvider(props: ToastProviderProps) -> Element { 72 rsx! { 73 // Render children 74 {props.children} 75 76 // Toast container - fixed position overlay 77 div { 78 class: "fixed bottom-4 right-0 z-50 flex flex-col-reverse space-y-2 space-y-reverse w-full max-w-sm px-4 items-end pointer-events-none", 79 aria_live: "polite", 80 aria_atomic: true, 81 82 for toast in TOASTS.read().iter() { 83 Toast { 84 key: "{toast.id}", 85 toast: toast.clone(), 86 default_duration: props.default_duration, 87 } 88 } 89 } 90 } 91 } 92 93 // Toast props 94 #[derive(Props, Clone, PartialEq)] 95 pub struct ToastProps { 96 pub toast: ToastItem, 97 pub default_duration: Duration, 98 } 99 100 // Toast component 101 #[component] 102 pub fn Toast(props: ToastProps) -> Element { 103 let toast = props.toast.clone(); 104 let id = toast.id; 105 let mut visible = use_signal(|| true); 106 107 // Handle removing toast from the list 108 let remove_toast = move || { 109 let mut toasts = TOASTS.write(); 110 if let Some(pos) = toasts.iter().position(|t| t.id == id) { 111 toasts.remove(pos); 112 } 113 }; 114 115 // Handle starting exit animation 116 let start_exit = move |_| { 117 visible.with_mut(|val| *val = false); 118 }; 119 120 // Set up auto-dismiss timer if not permanent 121 if !toast.permanent { 122 let duration = toast.duration.unwrap_or(props.default_duration); 123 124 // Simple timeout effect 125 use_effect(move || { 126 let timer = use_timeout(duration, move |()| { 127 visible.with_mut(|val| *val = false); 128 }); 129 timer.action(()); 130 }); 131 } 132 133 // Base styling 134 let base_classes = "pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-md hover:shadow-lg transition-all duration-300 group backdrop-blur-sm"; 135 136 // Animation classes based on state 137 let animation_classes = if !*visible.read() { 138 "animate-slide-out-to-right" 139 } else { 140 "animate-slide-in-from-right" 141 }; 142 143 // Toast type specific classes 144 let type_classes = toast.toast_type.classes(); 145 146 // Combined classes 147 let combined_classes = format!("{} {} {}", base_classes, animation_classes, type_classes); 148 149 rsx! { 150 div { 151 role: "alert", 152 class: "{combined_classes}", 153 tabindex: "0", // Make focusable 154 aria_labelledby: format!("toast-title-{}", toast.id), 155 aria_describedby: if toast.description.is_some() { 156 Some(format!("toast-desc-{}", toast.id)) 157 } else { 158 None 159 }, 160 // Simplified: no pause-on-hover for initial implementation 161 onanimationend: move |_| { 162 // If toast is not visible, remove it when animation ends 163 if !*visible.read() { 164 remove_toast(); 165 } 166 }, 167 168 div { 169 class: "flex items-center space-x-3 flex-1", 170 171 div { 172 class: "flex-shrink-0 {toast.toast_type.icon_classes()}", 173 aria_label: match toast.toast_type { 174 ToastType::Success => "Success:", 175 ToastType::Error => "Error:", 176 ToastType::Warning => "Warning:", 177 ToastType::Info => "Information:", 178 }, 179 {toast.toast_type.icon_component()} 180 } 181 182 div { 183 class: "flex-1 space-y-1", 184 185 div { 186 class: "text-sm font-semibold leading-none tracking-tight", 187 id: format!("toast-title-{}", toast.id), 188 "{toast.title}" 189 } 190 191 if let Some(description) = &toast.description { 192 div { 193 class: "text-sm opacity-90", 194 id: format!("toast-desc-{}", toast.id), 195 "{description}" 196 } 197 } 198 } 199 } 200 201 Button { 202 variant: ButtonVariant::Ghost, 203 is_icon_button: true, 204 aria_label: Some("Close".to_string()), 205 on_click: start_exit, 206 class: "absolute right-2 top-2 opacity-0 group-hover:opacity-100", 207 X { class: "size-4" } 208 } 209 } 210 } 211 } 212 213 // Toast options struct for easier API 214 #[derive(Clone, Default)] 215 pub struct ToastOptions { 216 pub description: Option<String>, 217 pub duration: Option<Duration>, 218 pub permanent: bool, 219 } 220 221 // Simplified toast API 222 #[derive(Clone, Copy)] 223 pub struct Toasts; 224 225 impl Toasts { 226 // Show a toast with the given type and options 227 pub fn show(&self, title: String, toast_type: ToastType, options: ToastOptions) { 228 let mut next_id = NEXT_ID.write(); 229 let id = *next_id; 230 *next_id += 1; 231 232 let toast = ToastItem { 233 id, 234 title, 235 description: options.description, 236 toast_type, 237 duration: if options.permanent { 238 None 239 } else { 240 options.duration 241 }, 242 permanent: options.permanent, 243 visible: true, 244 }; 245 246 let mut toasts = TOASTS.write(); 247 toasts.push(toast); 248 249 // Limit the number of toasts 250 while toasts.len() > 10 { 251 // Try to remove non-permanent toasts first 252 if let Some(pos) = toasts.iter().position(|t| !t.permanent) { 253 toasts.remove(pos); 254 } else { 255 toasts.remove(0); 256 } 257 } 258 } 259 260 // Convenience methods for different toast types 261 pub fn success(&self, title: String, options: Option<ToastOptions>) { 262 self.show(title, ToastType::Success, options.unwrap_or_default()); 263 } 264 265 pub fn error(&self, title: String, options: Option<ToastOptions>) { 266 self.show(title, ToastType::Error, options.unwrap_or_default()); 267 } 268 269 pub fn warning(&self, title: String, options: Option<ToastOptions>) { 270 self.show(title, ToastType::Warning, options.unwrap_or_default()); 271 } 272 273 pub fn info(&self, title: String, options: Option<ToastOptions>) { 274 self.show(title, ToastType::Info, options.unwrap_or_default()); 275 } 276 } 277 278 // Hook to use the toast API 279 pub fn use_toast() -> Toasts { 280 Toasts 281 }