AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/ui/src/kit/dialog.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

use super::{button, icons, uid, Size, Variant};
use crate::dom::{listen, portal, when, El, IntoView, NodeRef, View};
use crate::host::DOCUMENT;
use crate::html::{div, h2, p};
use crate::platform::{active_element, contains, focus, focus_first, set_timeout};
use crate::reactive::{on_cleanup, Signal};

/// A modal dialog (Radix Dialog semantics): portalled to `<body>`, labelled
/// by its title, closes on Escape / overlay click / the ✕ button, moves focus
/// inside on open, keeps it there, and restores it on close.
pub fn dialog<V: IntoView>(
    open: Signal<bool>,
    title: impl Fn() -> String + 'static,
    body: impl Fn() -> V + 'static,
) -> View {
    when(
        move || open.get(),
        move || {
            let title_id = uid("dialog-title");
            let panel = NodeRef::new();
            let previous = active_element();
            listen(DOCUMENT, "keydown", move |e| {
                if e.key() == "Escape" {
                    e.prevent_default();
                    open.set(false);
                }
            });
            let trap = panel.clone();
            listen(DOCUMENT, "focusin", move |e| {
                if let (Some(p), Some(t)) = (trap.get(), e.target()) {
                    if !contains(p, t) {
                        focus_first(p);
                    }
                }
            });
            let first = panel.clone();
            set_timeout(0, move || {
                if let Some(p) = first.get() {
                    focus_first(p);
                }
            });
            on_cleanup(move || {
                if let Some(n) = previous {
                    focus(n);
                }
            });
            portal((
                div()
                    .class("ak-overlay fixed inset-0 z-40 bg-black/60 backdrop-blur-sm")
                    .attr("aria-hidden", "true")
                    .on_click(move |_| open.set(false)),
                div()
                    .node_ref(&panel)
                    .role("dialog")
                    .attr("aria-modal", "true")
                    .attr("aria-labelledby", title_id.clone())
                    .class("ak-dialog fixed left-1/2 top-1/2 z-50 grid max-h-[90dvh] w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-md border border-border bg-bg-2 p-6 shadow-lg")
                    .child((
                        h2().id(title_id)
                            .class("pr-8 text-lg font-semibold leading-none tracking-tight")
                            .child(title()),
                        body(),
                        super::icon_button(Variant::Ghost, "Close", icons::x())
                            .class("absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-md text-muted hover:bg-panel hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent")
                            .on_click(move |_| open.set(false)),
                    )),
            ))
        },
    )
}

/// The dialog's action row (stacked on phones, right-aligned from `sm`).
pub fn dialog_footer() -> El {
    div().class("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end")
}

/// A confirmation dialog for destructive actions (Radix AlertDialog).
pub fn confirm_dialog(
    open: Signal<bool>,
    title: &'static str,
    message: impl Fn() -> String + 'static,
    confirm_label: &'static str,
    on_confirm: impl Fn() + Clone + 'static,
) -> View {
    dialog(
        open,
        move || title.to_string(),
        move || {
            let confirm = on_confirm.clone();
            (
                p().class("text-sm text-muted").child(message()),
                dialog_footer().child((
                    button(Variant::Outline, Size::Md)
                        .on_click(move |_| open.set(false))
                        .child("Cancel"),
                    button(Variant::Destructive, Size::Md)
                        .on_click(move |_| {
                            confirm();
                            open.set(false);
                        })
                        .child(confirm_label),
                )),
            )
        },
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::reactive::signal;
    use crate::testing;

    fn open_dialog() -> Signal<bool> {
        testing::render_with(|| {
            let open = signal(false);
            (
                (
                    button(Variant::Primary, Size::Md)
                        .on_click(move |_| open.set(true))
                        .child("Open"),
                    dialog(
                        open,
                        || "Edit event".into(),
                        || crate::kit::input().id("first"),
                    ),
                ),
                open,
            )
        })
    }

    #[test]
    fn opens_labelled_and_focuses_inside() {
        let open = open_dialog();
        let trigger = testing::get("button:text(Open)");
        crate::platform::focus(trigger);
        testing::click(trigger);
        testing::run_timers();
        let d = testing::get("[role=dialog]");
        let title = testing::attr(d, "aria-labelledby").unwrap();
        assert_eq!(
            testing::text(testing::get(&format!("h2#{title}"))),
            "Edit event"
        );
        assert_eq!(testing::focused(), testing::find("input#first"));
        open.set(false);
        assert!(testing::find("[role=dialog]").is_none());
        assert_eq!(testing::focused(), Some(trigger), "focus restored");
    }

    #[test]
    fn escape_and_close_button_dismiss() {
        let open = open_dialog();
        open.set(true);
        testing::key(testing::get("input#first"), "Escape");
        assert!(!open.get_untracked());
        open.set(true);
        testing::click(testing::get("button[aria-label=Close]"));
        assert!(!open.get_untracked());
    }

    #[test]
    fn closing_releases_listeners() {
        let open = open_dialog();
        let base = crate::live_callbacks();
        open.set(true);
        testing::run_timers();
        open.set(false);
        assert_eq!(crate::live_callbacks(), base);
    }
}