/ libs / ui / src / components / toast.rs
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  }