use rdom_core::NodeId;
use crate::TuiDom;
use crate::layout::Overflow;
use crate::node::TuiNodeExt;
pub fn tab_index(dom: &TuiDom, id: NodeId) -> Option<i32> {
let node = dom.node(id);
if dom.is_actually_disabled(id) {
return None;
}
if !is_rendered(dom, id) {
return None;
}
if let Some(t) = node
.get_attribute("tabindex")
.and_then(|s| s.parse::<i32>().ok())
{
return Some(t);
}
if is_implicit_focusable(dom, id) {
return Some(0);
}
None
}
fn is_implicit_focusable(dom: &TuiDom, id: NodeId) -> bool {
if intrinsic_tag_focusable(dom, id) {
return true;
}
is_scroll_container(dom, id) && !has_focusable_descendant(dom, id)
}
fn intrinsic_tag_focusable(dom: &TuiDom, id: NodeId) -> bool {
let node = dom.node(id);
let Some(tag) = node.tag_name() else {
return false;
};
match tag {
"input" => dom.input_type_state(id) != Some(rdom_core::InputTypeState::Hidden),
"button" | "textarea" | "select" => true,
"summary" => true,
"a" | "area" => node.has_attribute("href"),
_ => node.get_attribute("role") == Some("tree"),
}
}
fn is_scroll_container(dom: &TuiDom, id: NodeId) -> bool {
let node = dom.node(id);
let (Some(ext), Some(c)) = (node.tui_ext(), node.computed()) else {
return false;
};
let pb = crate::layout::compute_padding_box(ext.layout, c.border);
let scrolls_y = matches!(c.overflow_y, Overflow::Scroll | Overflow::Auto)
&& ext.scroll_content_height > pb.height as usize;
let scrolls_x = matches!(c.overflow_x, Overflow::Scroll | Overflow::Auto)
&& ext.scroll_content_width > pb.width as usize;
scrolls_x || scrolls_y
}
fn has_focusable_descendant(dom: &TuiDom, id: NodeId) -> bool {
let mut stack: Vec<NodeId> = dom.node(id).children().map(|c| c.id()).collect();
while let Some(d) = stack.pop() {
let node = dom.node(d);
let explicit_nonneg = node
.get_attribute("tabindex")
.and_then(|s| s.parse::<i32>().ok())
.is_some_and(|t| t >= 0);
let enabled = !dom.is_actually_disabled(d);
if (enabled && (explicit_nonneg || intrinsic_tag_focusable(dom, d)))
|| is_scroll_container(dom, d)
{
return true;
}
stack.extend(dom.node(d).children().map(|c| c.id()));
}
false
}
pub fn is_focusable(dom: &TuiDom, id: NodeId) -> bool {
tab_index(dom, id).is_some()
}
pub fn is_tab_focusable(dom: &TuiDom, id: NodeId) -> bool {
tab_index(dom, id).is_some_and(|t| t >= 0)
}
pub fn focusable_elements(dom: &TuiDom) -> Vec<NodeId> {
let mut positive: Vec<(i32, usize, NodeId)> = Vec::new();
let mut zero: Vec<(usize, NodeId)> = Vec::new();
let mut order: usize = 0;
let scope = crate::runtime::builtins::dialog::top_modal(dom).unwrap_or(dom.root());
collect(dom, scope, &mut positive, &mut zero, &mut order);
positive.sort_by_key(|(ti, ord, _)| (*ti, *ord));
zero.sort_by_key(|(ord, _)| *ord);
let raw: Vec<NodeId> = positive
.into_iter()
.map(|(_, _, id)| id)
.chain(zero.into_iter().map(|(_, id)| id))
.collect();
dedupe_radio_groups(dom, raw)
}
fn dedupe_radio_groups(dom: &TuiDom, list: Vec<NodeId>) -> Vec<NodeId> {
let mut reps: Vec<NodeId> = Vec::new();
for &id in &list {
if !is_named_radio(dom, id) {
continue;
}
match reps.iter().position(|&r| dom.in_same_radio_group(r, id)) {
None => reps.push(id),
Some(i) => {
let is_checked = dom.node(id).has_attribute("checked");
if is_checked && !dom.node(reps[i]).has_attribute("checked") {
reps[i] = id;
}
}
}
}
list.into_iter()
.filter(|&id| !is_named_radio(dom, id) || reps.contains(&id))
.collect()
}
fn is_named_radio(dom: &TuiDom, id: NodeId) -> bool {
dom.input_type_state(id) == Some(rdom_core::InputTypeState::Radio)
&& dom
.node(id)
.get_attribute("name")
.is_some_and(|n| !n.is_empty())
}
fn is_rendered(dom: &TuiDom, id: NodeId) -> bool {
dom.node(id)
.computed()
.is_none_or(|c| c.display != crate::layout::Display::None)
}
fn collect(
dom: &TuiDom,
id: NodeId,
positive: &mut Vec<(i32, usize, NodeId)>,
zero: &mut Vec<(usize, NodeId)>,
order: &mut usize,
) {
if !is_rendered(dom, id) {
return;
}
*order += 1;
let current_order = *order;
if let Some(t) = tab_index(dom, id) {
if t > 0 {
positive.push((t, current_order, id));
} else if t == 0 {
zero.push((current_order, id));
}
}
for child in dom.node(id).child_nodes() {
collect(dom, child.id(), positive, zero, order);
}
}
pub fn focus_next(dom: &mut TuiDom) {
step_focus(dom, 1);
}
pub fn focus_prev(dom: &mut TuiDom) {
step_focus(dom, -1);
}
fn step_focus(dom: &mut TuiDom, direction: i32) {
let list = focusable_elements(dom);
if list.is_empty() {
return;
}
let target = match dom
.focused()
.and_then(|cur| list.iter().position(|&e| e == cur))
{
Some(i) => {
let len = list.len() as i32;
let next = (i as i32 + direction).rem_euclid(len);
list[next as usize]
}
None => {
if direction > 0 {
list[0]
} else {
list[list.len() - 1]
}
}
};
super::focus_node(dom, Some(target));
}
#[cfg(test)]
mod focusable_tests {
use super::*;
use crate::layout::{Overflow, Size};
use crate::render::{LayoutExt, Rect};
use crate::style::{CascadeExt, Stylesheet, TuiStyle};
use crate::{TuiDom, TuiNodeMutExt};
fn scroll_div(tall: bool, build: impl FnOnce(&mut TuiDom, NodeId)) -> (TuiDom, NodeId) {
let mut dom = TuiDom::new();
let root = dom.root();
let d = dom.create_element("div");
dom.node_mut(d).set_inline_style(
TuiStyle::new()
.height(Size::Fixed(4))
.overflow_y(Overflow::Scroll),
);
build(&mut dom, d);
dom.append_child(root, d).unwrap();
dom.cascade(&Stylesheet::new());
dom.layout_dom(Rect::new(0, 0, 20, 10));
if let Some(ext) = dom.node_mut(d).ext_mut() {
ext.scroll_content_height = if tall { 50 } else { 2 };
}
(dom, d)
}
#[test]
fn scrollable_div_is_focusable() {
let (dom, d) = scroll_div(true, |_, _| {});
assert!(
is_focusable(&dom, d),
"a scrollable overflow div is Tab-focusable"
);
}
#[test]
fn non_overflowing_overflow_div_is_not_focusable() {
let (dom, d) = scroll_div(false, |_, _| {});
assert!(
!is_focusable(&dom, d),
"content fits → no scrollbar → not focusable"
);
}
#[test]
fn plain_div_is_not_focusable() {
let mut dom = TuiDom::new();
let root = dom.root();
let d = dom.create_element("div");
dom.append_child(root, d).unwrap();
dom.cascade(&Stylesheet::new());
dom.layout_dom(Rect::new(0, 0, 20, 10));
assert!(
!is_focusable(&dom, d),
"a non-scrolling div is not focusable"
);
}
#[test]
fn scroll_container_with_focusable_child_is_not_a_redundant_stop() {
let (dom, d) = scroll_div(true, |dom, parent| {
let b = dom.create_element("button");
dom.append_child(parent, b).unwrap();
});
assert!(
!is_focusable(&dom, d),
"the button is the stop; the enclosing scroller must not also be one"
);
}
}