Menu
Public repository / ef2b6f39a839842d6f1fa9f1d7d401dee73d9e15
AkurAI-Framework / crates/ui/src/kit/toast.rs
Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson
use super::icons;
use crate::dom::{each, portal, IntoView};
use crate::html::{button, div, p};
use crate::platform::set_timeout;
use crate::reactive::{provide_context, signal, use_context, Signal};
#[derive(Clone, PartialEq, Debug)]
struct Toast {
id: u32,
message: String,
error: bool,
}
#[derive(Clone, Copy)]
struct Toaster {
items: Signal<Vec<Toast>>,
next: Signal<u32>,
}
const LIFETIME_MS: u32 = 4000;
/// Provide toasts to descendants and render the live region. Mount once,
/// near the app root. Toasts sit top-right on phones (clear of bottom
/// navigation bars) and bottom-right from `sm` up.
pub fn provide_toaster() -> impl IntoView {
let items = signal(Vec::<Toast>::new());
provide_context(Toaster {
items,
next: signal(1),
});
portal(
div()
.attr("aria-live", "polite")
.attr("role", "status")
.class("pointer-events-none fixed right-0 top-0 z-[60] flex w-full max-w-sm flex-col gap-2 p-4 sm:bottom-0 sm:top-auto")
.child(each(
move || items.get(),
|t| t.id,
move |t| {
let Toast { id, message, error } = t.get_untracked();
div()
.class(if error {
"ak-toast pointer-events-auto flex items-start gap-3 rounded-md border border-danger/50 bg-bg-2 p-4 text-sm text-fg shadow-lg"
} else {
"ak-toast pointer-events-auto flex items-start gap-3 rounded-md border border-border bg-bg-2 p-4 text-sm text-fg shadow-lg"
})
.child((
p().class("flex-1").child(message),
button()
.typ("button")
.attr("aria-label", "Dismiss")
.class("text-muted hover:text-fg")
.on_click(move |_| dismiss(items, id))
.child(icons::x().class("size-4")),
))
},
)),
)
}
fn dismiss(items: Signal<Vec<Toast>>, id: u32) {
items.update(|list| list.retain(|t| t.id != id));
}
fn push(message: String, error: bool) {
let Some(t) = use_context::<Toaster>() else {
crate::platform::log(&message);
return;
};
let id = t.next.get_untracked();
t.next.set(id + 1);
t.items
.update(|list| list.push(Toast { id, message, error }));
set_timeout(LIFETIME_MS, move || dismiss(t.items, id));
}
/// Show a short confirmation.
pub fn toast(message: impl Into<String>) {
push(message.into(), false);
}
/// Show an error.
pub fn toast_error(message: impl Into<String>) {
push(message.into(), true);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing;
#[test]
fn toasts_appear_and_expire() {
testing::render(|| {
(
provide_toaster(),
button().on_click(|_| toast("Saved")).child("go"),
)
});
testing::click(testing::get("button:text(go)"));
assert!(testing::body_html().contains("Saved"));
testing::run_timers();
assert!(!testing::body_html().contains("Saved"));
}
}