use dioxus::prelude::*;
use crate::{
cn,
uikit::{TOAST_BASE, TOAST_CLOSE, TOAST_CONTENT, TOAST_TITLE, TOASTER_BASE, ToastPosition, ToastVariant, primitives::is_transform_transition},
};
const VISIBLE_TOASTS: usize = 3;
const GAP: u32 = 14;
const TOAST_HEIGHT_EST: u32 = 56;
const DEFAULT_DURATION_MS: u32 = 4000;
#[derive(Clone, Copy, Default, PartialEq)]
pub enum ToastState {
#[default]
Open,
Closing,
}
impl ToastState {
fn as_str(&self) -> &'static str {
match self {
ToastState::Open => "open",
ToastState::Closing => "closed",
}
}
}
#[derive(Clone, PartialEq)]
pub struct Toast {
pub id: u64,
pub message: String,
pub variant: ToastVariant,
pub state: ToastState,
pub duration: Option<u32>,
}
#[derive(Clone, Copy)]
pub struct Toasts {
items: Signal<Vec<Toast>>,
next_id: Signal<u64>,
}
#[derive(Clone, Copy)]
pub struct ToasterHandle {
toasts: Toasts,
}
impl ToasterHandle {
fn push(&self, message: impl Into<String>, variant: ToastVariant, duration: Option<u32>) -> u64 {
let mut next_id = self.toasts.next_id;
let id = *next_id.peek();
next_id.set(id + 1);
let mut items = self.toasts.items;
items.write().push(Toast {
id,
message: message.into(),
variant,
state: ToastState::Open,
duration,
});
id
}
pub fn toast(&self, message: impl Into<String>) -> u64 {
self.push(message, ToastVariant::Neutral, Some(DEFAULT_DURATION_MS))
}
pub fn positive(&self, message: impl Into<String>) -> u64 {
self.push(message, ToastVariant::Positive, Some(DEFAULT_DURATION_MS))
}
pub fn error(&self, message: impl Into<String>) -> u64 {
self.push(message, ToastVariant::Error, Some(DEFAULT_DURATION_MS))
}
pub fn info(&self, message: impl Into<String>) -> u64 {
self.push(message, ToastVariant::Info, Some(DEFAULT_DURATION_MS))
}
pub fn warn(&self, message: impl Into<String>) -> u64 {
self.push(message, ToastVariant::Warn, Some(DEFAULT_DURATION_MS))
}
pub fn show(&self, message: impl Into<String>, variant: ToastVariant, duration: Option<u32>) -> u64 {
self.push(message, variant, duration)
}
pub fn dismiss(&self, id: u64) {
let mut items = self.toasts.items;
if let Some(toast) = items.write().iter_mut().find(|t| t.id == id) {
toast.state = ToastState::Closing;
}
}
fn remove(&self, id: u64) {
let mut items = self.toasts.items;
items.write().retain(|t| t.id != id);
}
}
#[component]
pub fn ToasterProvider(children: Element) -> Element {
let items = use_signal(Vec::new);
let next_id = use_signal(|| 0u64);
use_context_provider(|| Toasts { items, next_id });
rsx! {
{children}
}
}
pub fn use_toaster() -> ToasterHandle {
ToasterHandle { toasts: use_context::<Toasts>() }
}
#[component]
pub fn Toaster(#[props(default)] position: ToastPosition, #[props(default)] class: String) -> Element {
let toasts = use_context::<Toasts>();
let y = match position {
ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight => "top",
_ => "bottom",
};
let cls = cn!(TOASTER_BASE, position.as_class(), class);
let items = toasts.items.read();
let total = items.len();
rsx! {
ol {
class: cls,
"data-slot": "toaster",
"data-position": position.as_ref(),
"data-y-position": y,
"data-stack": "",
style: "--front-height: {TOAST_HEIGHT_EST}px; --gap: {GAP}px;",
for (index, t) in items.iter().rev().cloned().enumerate() {
ToastItem { key: "{t.id}", toast: t, index, total }
}
}
}
}
#[component]
fn ToastItem(toast: Toast, index: usize, total: usize) -> Element {
let handle = use_toaster();
let id = toast.id;
let state = toast.state;
let mounted_closing = use_hook(|| state == ToastState::Closing);
use_effect(move || {
if mounted_closing {
handle.remove(id);
}
});
let front = index == 0;
let visible = index < VISIBLE_TOASTS;
let offset = index as u32 * (TOAST_HEIGHT_EST + GAP);
let z = total - index;
let autodismiss = toast.duration.is_some();
let life_ms = toast.duration.unwrap_or(0);
rsx! {
li {
role: "status",
"aria-live": "polite",
"data-slot": "toast",
"data-variant": toast.variant.as_ref(),
"data-state": state.as_str(),
"data-front": "{front}",
"data-visible": "{visible}",
"data-autodismiss": "{autodismiss}",
style: "--index: {index}; --z-index: {z}; --offset: {offset}px; --initial-height: {TOAST_HEIGHT_EST}px; --life: {life_ms}ms;",
onanimationend: move |e| {
if e.animation_name() == "ev-toast-life" {
handle.dismiss(id);
}
},
ontransitionend: move |e| {
if state == ToastState::Closing && is_transform_transition(&e) {
handle.remove(id);
}
},
class: cn!(TOAST_BASE, toast.variant.as_class()),
div { class: TOAST_CONTENT,
div { class: TOAST_TITLE, "{toast.message}" }
}
button {
r#type: "button",
"aria-label": "Close",
"data-slot": "toast-close",
class: TOAST_CLOSE,
onclick: move |_| handle.dismiss(id),
svg {
xmlns: "http://www.w3.org/2000/svg",
width: "16",
height: "16",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"aria-hidden": "true",
path { d: "M18 6 6 18M6 6l12 12" }
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::uikit::test_util::render;
#[test]
fn enqueued_toast_renders_with_role_and_variant() {
fn app() -> Element {
rsx! {
ToasterProvider {
Seed {}
Toaster {}
}
}
}
#[component]
fn Seed() -> Element {
let toaster = use_toaster();
use_hook(move || toaster.positive("Done"));
rsx! {}
}
let html = render(app);
assert!(html.contains("role=\"status\""), "{html}");
assert!(html.contains("data-variant=\"positive\""), "{html}");
assert!(html.contains("data-state=\"open\""), "fresh toast mounts open: {html}");
assert!(html.contains("Done"), "{html}");
assert!(html.contains("data-slot=\"toast\""), "{html}");
}
#[test]
fn close_button_carries_the_dismiss_path() {
fn app() -> Element {
rsx! {
ToasterProvider {
Seed {}
Toaster {}
}
}
}
#[component]
fn Seed() -> Element {
let toaster = use_toaster();
use_hook(move || toaster.error("Oops"));
rsx! {}
}
let html = render(app);
assert!(html.contains("M18 6 6 18M6 6l12 12"), "{html}");
assert!(html.contains("aria-label=\"Close\""), "{html}");
}
#[test]
fn every_variant_helper_enqueues() {
fn app() -> Element {
rsx! {
ToasterProvider {
Seed {}
Toaster {}
}
}
}
#[component]
fn Seed() -> Element {
let toaster = use_toaster();
use_hook(move || {
toaster.toast("plain");
toaster.info("info");
toaster.warn("warn");
});
rsx! {}
}
let html = render(app);
assert!(html.contains("data-variant=\"info\""), "{html}");
assert!(html.contains("data-variant=\"warn\""), "{html}");
}
#[test]
fn dismiss_marks_toast_closing_for_exit_animation() {
fn app() -> Element {
rsx! {
ToasterProvider {
Seed {}
Toaster {}
}
}
}
#[component]
fn Seed() -> Element {
let toaster = use_toaster();
use_hook(move || {
let id = toaster.positive("keep");
toaster.dismiss(id);
});
rsx! {}
}
let html = render(app);
assert!(html.contains("keep"), "closing toast still mounted: {html}");
assert!(html.contains("data-state=\"closed\""), "{html}");
}
#[test]
fn a_toast_dismissed_before_first_paint_is_dropped_on_mount() {
fn app() -> Element {
rsx! {
ToasterProvider {
Seed {}
Toaster {}
}
}
}
#[component]
fn Seed() -> Element {
let toaster = use_toaster();
use_hook(move || {
let id = toaster.info("cancelled");
toaster.dismiss(id);
});
rsx! {}
}
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
dom.render_immediate(&mut dioxus_core::NoOpMutations);
let html = dioxus_ssr::render(&dom);
assert!(!html.contains("cancelled"), "ghost toast left mounted: {html}");
}
#[test]
fn position_drives_the_root_attribute() {
fn app() -> Element {
rsx! {
ToasterProvider {
Toaster { position: ToastPosition::TopCenter }
}
}
}
let html = render(app);
assert!(html.contains("data-position=\"top-center\""), "{html}");
assert!(html.contains("data-y-position=\"top\""), "{html}");
assert!(html.contains("data-stack"), "stack model is on: {html}");
assert!(html.contains("data-slot=\"toaster\""), "{html}");
}
#[test]
fn stack_marks_front_and_depth() {
fn app() -> Element {
rsx! {
ToasterProvider {
Seed {}
Toaster {}
}
}
}
#[component]
fn Seed() -> Element {
let toaster = use_toaster();
use_hook(move || {
toaster.info("older");
toaster.warn("newer");
});
rsx! {}
}
let html = render(app);
assert!(html.contains("data-front=\"true\""), "a front toast: {html}");
assert!(html.contains("data-front=\"false\""), "a back toast: {html}");
assert!(html.contains("--index: 0"), "{html}");
assert!(html.contains("--index: 1"), "{html}");
assert!(html.contains("data-visible=\"true\""), "{html}");
}
#[test]
fn default_toasts_auto_dismiss_persistent_ones_do_not() {
fn app() -> Element {
rsx! {
ToasterProvider {
Seed {}
Toaster {}
}
}
}
#[component]
fn Seed() -> Element {
let toaster = use_toaster();
use_hook(move || {
toaster.positive("times out");
toaster.show("sticky", ToastVariant::Info, None);
});
rsx! {}
}
let html = render(app);
assert!(html.contains("data-autodismiss=\"true\""), "default auto-dismisses: {html}");
assert!(html.contains("--life: 4000ms"), "{html}");
assert!(html.contains("data-autodismiss=\"false\""), "persistent stays: {html}");
}
#[test]
fn position_serializes_kebab() {
assert_eq!(ToastPosition::TopLeft.as_ref(), "top-left");
}
}