feat: implement real Sonner toast animations with stacking and hover expansion
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
This commit is contained in:
@@ -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.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user