Compare commits

...

2 Commits

Author SHA1 Message Date
spinline
48d8a8e0ee feat: implement stacked toast accumulation with hover expansion effect
All checks were successful
Build MIPS Binary / build (push) Successful in 5m27s
2026-02-12 19:59:22 +03:00
spinline
945f4718eb feat: match bulk action button style with columns button and add progress bar to toasts
All checks were successful
Build MIPS Binary / build (push) Successful in 5m33s
2026-02-12 19:42:32 +03:00

View File

@@ -45,6 +45,7 @@ pub fn SonnerTrigger(
index: usize, index: usize,
total: usize, total: usize,
position: SonnerPosition, position: SonnerPosition,
is_expanded: Signal<bool>,
#[prop(optional)] on_dismiss: Option<Callback<()>>, #[prop(optional)] on_dismiss: Option<Callback<()>>,
) -> impl IntoView { ) -> impl IntoView {
let variant_classes = match toast.variant { let variant_classes = match toast.variant {
@@ -56,23 +57,39 @@ pub fn SonnerTrigger(
ToastType::Loading => "bg-background text-foreground border-border", ToastType::Loading => "bg-background text-foreground border-border",
}; };
// Sonner Stacking Logic let bar_color = match toast.variant {
let inverse_index = index; ToastType::Success => "bg-green-500",
let offset = inverse_index as f64 * 12.0; ToastType::Error => "bg-destructive",
let scale = 1.0 - (inverse_index as f64 * 0.05); ToastType::Warning => "bg-yellow-500",
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.15) }; ToastType::Info => "bg-blue-500",
_ => "bg-primary",
let is_bottom = position.to_string().contains("Bottom"); };
let y_direction = if is_bottom { -1.0 } else { 1.0 };
let translate_y = offset * y_direction;
let style = format!( // Stacking & Expansion Logic
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};", let style = move || {
total - index, let is_bottom = position.to_string().contains("Bottom");
translate_y, let y_direction = if is_bottom { -1.0 } else { 1.0 };
scale,
opacity let (translate_y, scale, opacity) = if is_expanded.get() {
); // Expanded state: Full list layout
let y = index as f64 * 70.0; // height + gap
(y * y_direction, 1.0, 1.0)
} else {
// Stacked state: Sonner look
let y = index as f64 * 10.0;
let s = 1.0 - (index as f64 * 0.05);
let o = if index > 2 { 0.0 } else { 1.0 - (index as f64 * 0.2) };
(y * y_direction, s, o)
};
format!(
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
total - index,
translate_y,
scale,
opacity
)
};
let icon = match toast.variant { let icon = match toast.variant {
ToastType::Success => Some(view! { <span class="icon font-bold">""</span> }.into_any()), ToastType::Success => Some(view! { <span class="icon font-bold">""</span> }.into_any()),
@@ -85,9 +102,9 @@ pub fn SonnerTrigger(
view! { view! {
<div <div
class=tw_merge!( class=tw_merge!(
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto", "absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto overflow-hidden",
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-lg bg-card", "flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-lg bg-card",
if is_bottom { "bottom-0" } else { "top-0" }, if position.to_string().contains("Bottom") { "bottom-0" } else { "top-0" },
variant_classes variant_classes
) )
style=style style=style
@@ -98,10 +115,19 @@ pub fn SonnerTrigger(
} }
> >
{icon} {icon}
<div class="flex flex-col gap-0.5 overflow-hidden"> <div class="flex flex-col gap-0.5 overflow-hidden flex-1">
<div class="text-sm font-semibold truncate leading-tight">{toast.title}</div> <div class="text-sm font-semibold truncate leading-tight">{toast.title}</div>
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70 truncate">{d.clone()}</div> })} {move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70 truncate">{d.clone()}</div> })}
</div> </div>
// Progress Bar
<div
class=tw_merge!("absolute bottom-0 left-0 h-1 w-full opacity-20", bar_color)
style=format!(
"animation: sonner-progress {}ms linear forwards; transform-origin: left;",
toast.duration
)
/>
</div> </div>
}.into_any() }.into_any()
} }
@@ -122,21 +148,23 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
let toasts = store.toasts; let toasts = store.toasts;
let is_hovered = RwSignal::new(false); let is_hovered = RwSignal::new(false);
let (container_class, mobile_class) = match position { let container_class = match position {
SonnerPosition::TopLeft => ("left-6 top-6 items-start", "left-4 top-4"), SonnerPosition::TopLeft => "left-6 top-6 items-start",
SonnerPosition::TopRight => ("right-6 top-6 items-end", "right-4 top-4"), SonnerPosition::TopRight => "right-6 top-6 items-end",
SonnerPosition::TopCenter => ("left-1/2 -translate-x-1/2 top-6 items-center", "left-1/2 -translate-x-1/2 top-4"), SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
SonnerPosition::BottomCenter => ("left-1/2 -translate-x-1/2 bottom-6 items-center", "left-1/2 -translate-x-1/2 bottom-4"), SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
SonnerPosition::BottomLeft => ("left-6 bottom-6 items-start", "left-4 bottom-4"), SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
SonnerPosition::BottomRight => ("right-6 bottom-6 items-end", "right-4 bottom-4"), SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
}; };
view! { view! {
<style>
"@keyframes sonner-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }"
</style>
<div <div
class=tw_merge!( class=tw_merge!(
"fixed z-[100] flex flex-col pointer-events-none min-h-[100px] w-full sm:w-[400px]", "fixed z-[100] flex flex-col pointer-events-none min-h-[100px] w-full sm:w-[400px]",
container_class, container_class,
// Safe areas for mobile
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0" "pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
) )
on:mouseenter=move |_| is_hovered.set(true) on:mouseenter=move |_| is_hovered.set(true)
@@ -152,29 +180,17 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
let id = toast.id; let id = toast.id;
let total = toasts.with(|t| t.len()); let total = toasts.with(|t| t.len());
let expanded_style = move || {
if is_hovered.get() {
let offset = index as f64 * 64.0;
let is_bottom = position.to_string().contains("Bottom");
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! { view! {
<div class="contents" style=expanded_style> <SonnerTrigger
<SonnerTrigger toast=toast
toast=toast index=index
index=index total=total
total=total position=position
position=position is_expanded=is_hovered.into()
on_dismiss=Callback::new(move |_| { on_dismiss=Callback::new(move |_| {
toasts.update(|vec| vec.retain(|t| t.id != id)); toasts.update(|vec| vec.retain(|t| t.id != id));
}) })
/> />
</div>
} }
} }
/> />