AkurAI Build
Menu

Public repository / ea03885a774def83b7188ebfd8797985d527c382

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"));
    }
}