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