use rdom_core::{ListenerOptions, NodeId};
use crate::{TuiDom, TuiEvent};
const MODAL_ATTR: &str = "data-rdom-modal";
const RETURN_VALUE_ATTR: &str = "data-rdom-return-value";
pub fn show(dom: &mut TuiDom, dialog: NodeId) {
let was_open = dom.node(dialog).has_attribute("open");
let _ = dom.set_attribute(dialog, "open", "");
let _ = dom.remove_attribute(dialog, MODAL_ATTR);
if !was_open {
fire_toggle(dom, dialog, rdom_core::ToggleState::Closed);
}
}
pub fn show_modal(dom: &mut TuiDom, dialog: NodeId) {
let was_open = dom.node(dialog).has_attribute("open");
let previous = dom.focused().filter(|&f| !is_inside(dom, f, dialog));
if let Some(ext) = dom.node_mut(dialog).ext_mut() {
ext.dialog_return_focus = previous;
}
let _ = dom.set_attribute(dialog, "open", "");
let _ = dom.set_attribute(dialog, MODAL_ATTR, "");
if !was_open {
fire_toggle(dom, dialog, rdom_core::ToggleState::Closed);
}
crate::runtime::autofocus::focus_within(dom, dialog);
if !dom.focused().is_some_and(|f| is_inside(dom, f, dialog)) {
let target = first_focusable_in(dom, dialog).or_else(|| {
crate::runtime::focus::tabindex::is_focusable(dom, dialog).then_some(dialog)
});
if let Some(t) = target {
crate::runtime::focus::focus_node(dom, Some(t));
}
}
}
fn is_inside(dom: &TuiDom, id: NodeId, ancestor: NodeId) -> bool {
let mut cur = Some(id);
while let Some(n) = cur {
if n == ancestor {
return true;
}
cur = dom.node(n).parent_node().map(|p| p.id());
}
false
}
fn first_focusable_in(dom: &TuiDom, root: NodeId) -> Option<NodeId> {
fn walk(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
for child in dom.node(id).child_nodes() {
let c = child.id();
if crate::runtime::focus::tabindex::is_focusable(dom, c) {
return Some(c);
}
if let Some(found) = walk(dom, c) {
return Some(found);
}
}
None
}
walk(dom, root)
}
pub fn top_modal(dom: &TuiDom) -> Option<NodeId> {
dom.get_elements_by_tag_name_all("dialog")
.into_iter()
.rev()
.find(|&d| dom.node(d).has_attribute("open") && dom.node(d).has_attribute(MODAL_ATTR))
}
pub fn close(dom: &mut TuiDom, dialog: NodeId, return_value: &str) {
if !dom.node(dialog).has_attribute("open") {
return; }
let _ = dom.remove_attribute(dialog, "open");
let _ = dom.remove_attribute(dialog, MODAL_ATTR);
let _ = dom.set_attribute(dialog, RETURN_VALUE_ATTR, return_value);
fire_toggle(dom, dialog, rdom_core::ToggleState::Open);
let mut ev = TuiEvent::new("close");
ev.event = ev.event.clone().with_bubbles(false);
crate::tui_event::dispatch_to_live(dom, dialog, &mut ev);
let previous = dom
.node_mut(dialog)
.ext_mut()
.and_then(|e| e.dialog_return_focus.take());
let still_focusable =
|p: &NodeId| dom.contains(*p) && crate::runtime::focus::tabindex::is_focusable(dom, *p);
if let Some(prev) = previous.filter(still_focusable) {
crate::runtime::focus::focus_node(dom, Some(prev));
} else if dom.focused().is_some_and(|f| is_inside(dom, f, dialog)) {
crate::runtime::focus::focus_node(dom, None);
}
}
fn fire_toggle(dom: &mut TuiDom, dialog: NodeId, old_state: rdom_core::ToggleState) {
let new_state = match old_state {
rdom_core::ToggleState::Open => rdom_core::ToggleState::Closed,
rdom_core::ToggleState::Closed => rdom_core::ToggleState::Open,
};
let mut ev = TuiEvent::new("toggle");
ev.event = ev.event.clone().with_bubbles(false);
ev.event.detail = rdom_core::EventDetail::Toggle(Box::new(rdom_core::ToggleDetail::new(
old_state, new_state,
)));
crate::tui_event::dispatch_to_live(dom, dialog, &mut ev);
}
pub fn return_value(dom: &TuiDom, dialog: NodeId) -> String {
dom.node(dialog)
.get_attribute(RETURN_VALUE_ATTR)
.unwrap_or("")
.to_string()
}
pub fn set_return_value(dom: &mut TuiDom, dialog: NodeId, value: &str) {
let _ = dom.set_attribute(dialog, RETURN_VALUE_ATTR, value);
}
pub fn is_modal(dom: &TuiDom, dialog: NodeId) -> bool {
dom.node(dialog).has_attribute("open") && dom.node(dialog).has_attribute(MODAL_ATTR)
}
pub fn enclosing_dialog(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
let mut cur = Some(id);
while let Some(n) = cur {
if dom.node(n).tag_name() == Some("dialog") {
return Some(n);
}
cur = dom.node(n).parent_node().map(|p| p.id());
}
None
}
pub fn install(dom: &mut TuiDom) {
let root = dom.root();
dom.add_event_listener(root, "keydown", ListenerOptions::default(), move |ctx| {
if ctx.event.default_prevented() {
return;
}
let Some(key) = ctx.event.detail.as_keyboard() else {
return;
};
let no_mods = !key.modifiers.ctrl
&& !key.modifiers.shift
&& !key.modifiers.alt
&& !key.modifiers.meta;
if key.key != "Escape" || !no_mods {
return;
}
let enclosing = ctx
.dom
.focused()
.and_then(|f| enclosing_dialog(ctx.dom, f))
.filter(|&d| is_modal(ctx.dom, d));
let Some(dialog) = enclosing.or_else(|| top_modal(ctx.dom)) else {
return;
};
let mut cancel = TuiEvent::new("cancel");
if !crate::tui_event::dispatch_to_live(ctx.dom, dialog, &mut cancel)
|| cancel.event.default_prevented()
{
return;
}
let rv = return_value(ctx.dom, dialog);
close(ctx.dom, dialog, &rv);
})
.expect("dialog Esc-cancel listener install");
}
#[cfg(test)]
mod tests;