use crate::components::shared::{CONTROL_MOTION, FOCUS_RING};
use crate::icons::{icondata, Icon};
use leptos::leptos_dom::helpers::set_timeout;
use leptos::portal::Portal;
use leptos::prelude::*;
use std::time::Duration;
#[derive(Clone, Copy, PartialEq, Default, Debug)]
pub enum ToastVariant {
#[default]
Default,
Success,
Destructive,
Warning,
Info,
}
#[derive(Clone, Debug)]
pub struct ToastItem {
pub id: usize,
pub title: String,
pub description: Option<String>,
pub variant: ToastVariant,
}
#[derive(Clone, Copy)]
pub struct ToastHandle {
queue: RwSignal<Vec<ToastItem>>,
next_id: RwSignal<usize>,
}
impl ToastHandle {
pub fn show(
&self,
title: impl Into<String>,
description: Option<String>,
variant: ToastVariant,
duration_ms: u64,
) {
let id = self.next_id.get();
self.next_id.update(|n| *n += 1);
let item = ToastItem {
id,
title: title.into(),
description,
variant,
};
self.queue.update(|q| q.push(item));
let queue = self.queue;
set_timeout(
move || queue.update(|q| q.retain(|t| t.id != id)),
Duration::from_millis(duration_ms),
);
}
pub fn dismiss(&self, id: usize) {
self.queue.update(|q| q.retain(|t| t.id != id));
}
}
pub fn use_toast() -> ToastHandle {
use_context::<ToastHandle>().expect("use_toast: <Toaster> not mounted")
}
#[component]
pub fn Toaster(children: ChildrenFn) -> impl IntoView {
let queue: RwSignal<Vec<ToastItem>> = RwSignal::new(Vec::new());
let next_id: RwSignal<usize> = RwSignal::new(0);
let handle = ToastHandle { queue, next_id };
provide_context(handle);
let children = StoredValue::new(children);
view! {
<>
{children.with_value(|c| c())}
<Portal>
<div class="fixed bottom-4 end-4 z-[100] flex flex-col gap-2 pointer-events-none w-full max-w-sm">
<For
each=move || queue.get()
key=|t| t.id
children=move |item| {
let id = item.id;
let variant_border = match item.variant {
ToastVariant::Default => "border-border",
ToastVariant::Success => "border-success/50",
ToastVariant::Destructive => "border-destructive/50",
ToastVariant::Warning => "border-yellow-500/50",
ToastVariant::Info => "border-blue-500/50",
};
let icon_view = match item.variant {
ToastVariant::Default => None,
ToastVariant::Success => Some(view! {
<Icon icon=Signal::derive(|| icondata::LuCheck) width="16" height="16" attr:class="text-success shrink-0 mt-0.5" />
}),
ToastVariant::Destructive => Some(view! {
<Icon icon=Signal::derive(|| icondata::LuX) width="16" height="16" attr:class="text-destructive shrink-0 mt-0.5" />
}),
ToastVariant::Warning => Some(view! {
<Icon icon=Signal::derive(|| icondata::LuTriangleAlert) width="16" height="16" attr:class="text-yellow-500 shrink-0 mt-0.5" />
}),
ToastVariant::Info => Some(view! {
<Icon icon=Signal::derive(|| icondata::LuInfo) width="16" height="16" attr:class="text-blue-500 shrink-0 mt-0.5" />
}),
};
let class = format!(
"pointer-events-auto flex w-full items-start gap-3 rounded-md border {} bg-card p-4 shadow-elev-lg animate-slide-up",
variant_border
);
let title = item.title.clone();
let description = item.description.clone();
view! {
<div class=class>
{icon_view}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground">{title}</p>
{description.map(|d| view! {
<p class="text-sm text-muted-foreground mt-0.5">{d}</p>
})}
</div>
<button
class=format!("flex items-center justify-center w-6 h-6 shrink-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent {} {}", CONTROL_MOTION, FOCUS_RING)
aria-label="Dismiss"
on:click=move |_| queue.update(|q| q.retain(|t| t.id != id))
>
<Icon icon=Signal::derive(|| icondata::LuX) width="14" height="14" />
</button>
</div>
}
}
/>
</div>
</Portal>
</>
}
}