feat: implement real Sonner toast animations with stacking and hover expansion
Some checks failed
Build MIPS Binary / build (push) Has been cancelled

This commit is contained in:
spinline
2026-02-12 00:25:57 +03:00
parent fa07fd88dc
commit 555505b80e

View File

@@ -25,13 +25,6 @@ pub enum SonnerPosition {
BottomLeft, BottomLeft,
} }
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
pub enum SonnerDirection {
TopDown,
#[default]
BottomUp,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct ToastData { pub struct ToastData {
pub id: u64, pub id: u64,
@@ -48,166 +41,145 @@ pub struct ToasterStore {
#[component] #[component]
pub fn SonnerTrigger( pub fn SonnerTrigger(
#[prop(into, optional)] class: String, toast: ToastData,
#[prop(optional, default = ToastType::default())] variant: ToastType, index: usize,
#[prop(into)] title: String, total: usize,
description: Option<String>, position: SonnerPosition,
#[prop(into, optional)] position: String, #[prop(optional)] on_dismiss: Option<Callback<()>>,
on_dismiss: Option<Callback<()>>,
) -> impl IntoView { ) -> impl IntoView {
let variant_classes = match variant { let variant_classes = match toast.variant {
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", ToastType::Default => "bg-background text-foreground border-border",
ToastType::Success => "bg-green-500 text-white hover:bg-green-600", ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-success",
ToastType::Error => "bg-red-500 text-white shadow-xs hover:bg-red-600", ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
ToastType::Warning => "bg-yellow-500 text-white hover:bg-yellow-600", ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-warning",
ToastType::Info => "bg-blue-500 text-white shadow-xs hover:bg-blue-600", ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-info",
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ToastType::Loading => "bg-background text-foreground border-border",
}; };
let animation_direction = if position.contains("Top") { // Sonner Stacking Logic
"slide-in-from-top-5" // We calculate inverse index: 0 is the newest (top), 1 is older, etc.
} else { let inverse_index = index;
"slide-in-from-bottom-5" let offset = inverse_index as f64 * 16.0;
}; let scale = 1.0 - (inverse_index as f64 * 0.05);
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.2) };
let merged_class = tw_merge!( let is_bottom = !position.to_string().contains("Top");
"inline-flex flex-col items-start justify-center gap-1 min-w-[300px] rounded-md text-sm font-medium shadow-lg p-4 cursor-pointer pointer-events-auto border border-border/50 transition-all", let y_direction = if is_bottom { -1.0 } else { 1.0 };
"animate-in fade-in duration-300 ease-out hover:scale-[1.02] active:scale-[0.98]", let translate_y = offset * y_direction;
animation_direction,
variant_classes, let style = format!(
class "z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
total - index,
translate_y,
scale,
opacity
); );
// Only set position attribute if not empty let icon = match toast.variant {
let position_attr = if position.is_empty() { None } else { Some(position) }; ToastType::Success => Some(view! { <span class="icon text-success">""</span> }.into_any()),
ToastType::Error => Some(view! { <span class="icon text-destructive">""</span> }.into_any()),
// Clone title for data attribute usage, original moved into view ToastType::Warning => Some(view! { <span class="icon text-warning">""</span> }.into_any()),
let title_clone = title.clone(); ToastType::Info => Some(view! { <span class="icon text-info">""</span> }.into_any()),
_ => None,
};
view! { view! {
<div <div
class=merged_class class=tw_merge!(
data-name="SonnerTrigger" "absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
data-variant=variant.to_string() "flex items-center gap-3 min-w-[350px] p-4 rounded-lg border shadow-lg bg-card",
data-toast-title=title_clone if is_bottom { "bottom-0" } else { "top-0" },
data-toast-position=position_attr variant_classes
)
style=style
on:click=move |_| { on:click=move |_| {
if let Some(cb) = on_dismiss { if let Some(cb) = on_dismiss {
cb.run(()); cb.run(());
} }
} }
> >
<div class="font-semibold">{title}</div> {icon}
{move || description.as_ref().map(|d| view! { <div class="text-xs opacity-90">{d.clone()}</div> })} <div class="flex flex-col gap-1">
<div class="text-sm font-semibold">{toast.title}</div>
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70">{d.clone()}</div> })}
</div>
</div> </div>
} }.into_any()
} }
#[component] // Thread local storage for global access
pub fn SonnerContainer(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
) -> impl IntoView {
let merged_class = tw_merge!("toast__container fixed z-[9999] flex flex-col gap-2 p-4 outline-none pointer-events-none", class);
view! {
<div class=merged_class data-position=position.to_string()>
{children()}
</div>
}
}
#[component]
pub fn SonnerList(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
#[prop(into, default = "false".to_string())] expanded: String,
#[prop(into, optional)] style: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"contents",
class
);
view! {
<div
class=merged_class
data-name="SonnerList"
data-sonner-toaster="true"
data-sonner-theme="light"
data-position=position.to_string()
data-expanded=expanded
data-direction=direction.to_string()
style=style
>
{children()}
</div>
}
}
// Thread local storage for global access without Context
thread_local! { thread_local! {
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None); static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
} }
pub fn provide_toaster() { pub fn provide_toaster() {
let toasts = RwSignal::new(Vec::<ToastData>::new()); let toasts = RwSignal::new(Vec::<ToastData>::new());
// Set global thread_local
TOASTS.with(|t| *t.borrow_mut() = Some(toasts)); TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
// Also provide context for components
provide_context(ToasterStore { toasts }); provide_context(ToasterStore { toasts });
} }
#[component] #[component]
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView { pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
// Global store'u al let store = use_context::<ToasterStore>().expect("Toaster context not found");
let store = use_context::<ToasterStore>().expect("Toaster context not found. Call provide_toaster() in App root.");
let toasts = store.toasts; let toasts = store.toasts;
let is_hovered = RwSignal::new(false);
// Auto-derive direction from position
let direction = match position {
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
_ => SonnerDirection::BottomUp,
};
let container_class = match position { let container_class = match position {
SonnerPosition::TopLeft => "left-0 top-0 items-start", SonnerPosition::TopLeft => "left-6 top-6 items-start",
SonnerPosition::TopRight => "right-0 top-0 items-end", SonnerPosition::TopRight => "right-6 top-6 items-end",
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-0 items-center", SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-0 items-center", SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
SonnerPosition::BottomLeft => "left-0 bottom-0 items-start", SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
SonnerPosition::BottomRight => "right-0 bottom-0 items-end", SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
}; };
view! { view! {
<SonnerContainer class=container_class position=position> <div
<SonnerList position=position direction=direction> class=tw_merge!("fixed z-[100] flex flex-col pointer-events-none min-h-[200px] w-[400px]", container_class)
<For on:mouseenter=move |_| is_hovered.set(true)
each=move || toasts.get() on:mouseleave=move |_| is_hovered.set(false)
key=|toast| toast.id >
children=move |toast| { <For
let id = toast.id; each=move || {
view! { let list = toasts.get();
<SonnerTrigger // Reverse the list so newest is at the end (for stacking)
variant=toast.variant // or newest is at the beginning (for display logic)
title=toast.title list.into_iter().rev().enumerate().collect::<Vec<_>>()
description=toast.description }
position=position.to_string() key=|(_, toast)| toast.id
on_dismiss=Some(Callback::new(move |_| { children=move |(index, toast)| {
toasts.update(|vec| vec.retain(|t| t.id != id)); let id = toast.id;
})) let total = toasts.with(|t| t.len());
/>
// If hovered, expand the stack
let expanded_style = move || {
if is_hovered.get() {
let offset = index as f64 * 70.0;
let is_bottom = !position.to_string().contains("Top");
let y_dir = if is_bottom { -1.0 } else { 1.0 };
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
} else {
"".to_string()
} }
};
view! {
<div style=expanded_style>
<SonnerTrigger
toast=toast
index=index
total=total
position=position
on_dismiss=Callback::new(move |_| {
toasts.update(|vec| vec.retain(|t| t.id != id));
})
/>
</div>
} }
/> }
</SonnerList> />
</SonnerContainer> </div>
} }.into_any()
} }
// Global Helper Functions // Global Helper Functions
@@ -224,16 +196,18 @@ pub fn toast(title: impl Into<String>, variant: ToastType) {
duration: 4000, duration: 4000,
}; };
toasts.update(|t| t.push(new_toast.clone())); toasts.update(|t| {
t.push(new_toast.clone());
if t.len() > 5 {
t.remove(0);
}
});
// Auto remove after duration
let duration = new_toast.duration; let duration = new_toast.duration;
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(duration as u32).await; gloo_timers::future::TimeoutFuture::new(duration as u32).await;
toasts.update(|vec| vec.retain(|t| t.id != id)); toasts.update(|vec| vec.retain(|t| t.id != id));
}); });
} else {
gloo_console::warn!("ToasterStore not found (global static). Make sure provide_toaster() is called.");
} }
} }