#[cfg(feature = "a11y-table")]
use oxiui_accessibility::tree::{
build_table_a11y as upstream_build_table, column_header_node, table_cell_node, table_row_node,
A11yNode, WidgetRole,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum A11yRole {
Group,
ColumnHeader,
TableRow,
TableCell,
}
#[derive(Debug)]
pub struct LightNode {
pub id: u64,
pub role: A11yRole,
pub label: Option<String>,
pub description: Option<String>,
pub is_selected: bool,
pub children: Vec<LightNode>,
}
pub struct TableA11yParams<'a> {
pub row_count: usize,
pub col_headers: &'a [&'a str],
pub selected_rows: &'a [usize],
pub first_node_id: u64,
}
pub fn build_table_a11y_tree(params: &TableA11yParams<'_>) -> LightNode {
let col_count = params.col_headers.len();
let mut next_id = params.first_node_id;
let mut root = LightNode {
id: next_id,
role: A11yRole::Group,
label: None,
description: None,
is_selected: false,
children: Vec::with_capacity(col_count + params.row_count),
};
next_id += 1;
for (col_idx, &header) in params.col_headers.iter().enumerate() {
root.children.push(LightNode {
id: next_id,
role: A11yRole::ColumnHeader,
label: Some(header.to_owned()),
description: Some(format!("Column {} header", col_idx + 1)),
is_selected: false,
children: Vec::new(),
});
next_id += 1;
}
for row_idx in 0..params.row_count {
let is_row_selected = params.selected_rows.contains(&row_idx);
let mut row = LightNode {
id: next_id,
role: A11yRole::TableRow,
label: None,
description: Some(format!("Row {}", row_idx + 1)),
is_selected: is_row_selected,
children: Vec::with_capacity(col_count),
};
next_id += 1;
for col_idx in 0..col_count {
row.children.push(LightNode {
id: next_id,
role: A11yRole::TableCell,
label: None,
description: Some(format!("Row {} Column {}", row_idx + 1, col_idx + 1)),
is_selected: false,
children: Vec::new(),
});
next_id += 1;
}
root.children.push(row);
}
root
}
pub struct TableA11yWithTextParams<'a> {
pub base: TableA11yParams<'a>,
pub cell_text: &'a [Vec<String>],
}
pub fn build_table_a11y_with_text(params: &TableA11yWithTextParams<'_>) -> LightNode {
let col_count = params.base.col_headers.len();
let mut next_id = params.base.first_node_id;
let mut root = LightNode {
id: next_id,
role: A11yRole::Group,
label: None,
description: None,
is_selected: false,
children: Vec::with_capacity(col_count + params.base.row_count),
};
next_id += 1;
for (col_idx, &header) in params.base.col_headers.iter().enumerate() {
root.children.push(LightNode {
id: next_id,
role: A11yRole::ColumnHeader,
label: Some(header.to_owned()),
description: Some(format!("Column {} header", col_idx + 1)),
is_selected: false,
children: Vec::new(),
});
next_id += 1;
}
for row_idx in 0..params.base.row_count {
let is_row_selected = params.base.selected_rows.contains(&row_idx);
let row_cells: &[String] = params
.cell_text
.get(row_idx)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let mut row = LightNode {
id: next_id,
role: A11yRole::TableRow,
label: None,
description: Some(format!("Row {}", row_idx + 1)),
is_selected: is_row_selected,
children: Vec::with_capacity(col_count),
};
next_id += 1;
for col_idx in 0..col_count {
let cell_label = row_cells.get(col_idx).cloned();
row.children.push(LightNode {
id: next_id,
role: A11yRole::TableCell,
label: cell_label,
description: Some(format!("Row {} Column {}", row_idx + 1, col_idx + 1)),
is_selected: false,
children: Vec::new(),
});
next_id += 1;
}
root.children.push(row);
}
root
}
#[cfg(feature = "a11y-table")]
pub fn build_table_a11y_full(row_count: usize, col_count: usize, col_headers: &[&str]) -> A11yNode {
upstream_build_table(row_count, col_count, col_headers)
}
#[cfg(feature = "a11y-table")]
pub fn build_table_a11y_full_with_text(
row_count: usize,
col_headers: &[&str],
col_count: usize,
cell_text: &[Vec<String>],
selected_rows: &[usize],
) -> A11yNode {
use accesskit::NodeId;
let mut next_id: u64 = 0;
let mut root = A11yNode::simple(NodeId(next_id), WidgetRole::Group, None);
next_id += 1;
for (col_idx, &header) in col_headers.iter().enumerate() {
let node = column_header_node(NodeId(next_id), col_idx, header);
next_id += 1;
root.children.push(node);
}
for row_idx in 0..row_count {
let mut row = table_row_node(NodeId(next_id), row_idx);
next_id += 1;
if selected_rows.contains(&row_idx) {
row.props.selected = Some(true);
}
let row_cells: &[String] = cell_text.get(row_idx).map(|v| v.as_slice()).unwrap_or(&[]);
for col_idx in 0..col_count {
let text = row_cells.get(col_idx).map(|s| s.as_str()).unwrap_or("");
let cell = table_cell_node(NodeId(next_id), row_idx, col_idx, text);
next_id += 1;
row.children.push(cell);
}
root.children.push(row);
}
root
}
#[cfg(test)]
mod tests {
use super::*;
fn make_params<'a>(
rows: usize,
headers: &'a [&'a str],
selected: &'a [usize],
) -> TableA11yParams<'a> {
TableA11yParams {
row_count: rows,
col_headers: headers,
selected_rows: selected,
first_node_id: 1,
}
}
#[test]
fn root_role_is_group() {
let params = make_params(2, &["A", "B"], &[]);
let root = build_table_a11y_tree(¶ms);
assert_eq!(root.role, A11yRole::Group);
}
#[test]
fn column_header_count_matches() {
let params = make_params(3, &["Col1", "Col2", "Col3"], &[]);
let root = build_table_a11y_tree(¶ms);
let headers: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::ColumnHeader)
.collect();
assert_eq!(headers.len(), 3);
}
#[test]
fn row_count_matches() {
let params = make_params(5, &["A", "B"], &[]);
let root = build_table_a11y_tree(¶ms);
let rows: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::TableRow)
.collect();
assert_eq!(rows.len(), 5);
}
#[test]
fn each_row_has_correct_cell_count() {
let params = make_params(2, &["X", "Y", "Z"], &[]);
let root = build_table_a11y_tree(¶ms);
for row in root
.children
.iter()
.filter(|n| n.role == A11yRole::TableRow)
{
assert_eq!(row.children.len(), 3, "each row must have 3 cells");
}
}
#[test]
fn selected_row_is_marked() {
let params = make_params(3, &["A"], &[1]);
let root = build_table_a11y_tree(¶ms);
let rows: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::TableRow)
.collect();
assert!(!rows[0].is_selected, "row 0 should not be selected");
assert!(rows[1].is_selected, "row 1 should be selected");
assert!(!rows[2].is_selected, "row 2 should not be selected");
}
#[test]
fn column_header_label_matches() {
let params = make_params(0, &["Name", "Age"], &[]);
let root = build_table_a11y_tree(¶ms);
let headers: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::ColumnHeader)
.collect();
assert_eq!(headers[0].label.as_deref(), Some("Name"));
assert_eq!(headers[1].label.as_deref(), Some("Age"));
}
#[test]
fn row_description_is_one_based() {
let params = make_params(2, &["A"], &[]);
let root = build_table_a11y_tree(¶ms);
let rows: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::TableRow)
.collect();
assert_eq!(rows[0].description.as_deref(), Some("Row 1"));
assert_eq!(rows[1].description.as_deref(), Some("Row 2"));
}
#[test]
fn cell_description_has_row_and_col() {
let params = make_params(1, &["A", "B"], &[]);
let root = build_table_a11y_tree(¶ms);
let row = root
.children
.iter()
.find(|n| n.role == A11yRole::TableRow)
.expect("must have a row");
assert_eq!(
row.children[0].description.as_deref(),
Some("Row 1 Column 1")
);
assert_eq!(
row.children[1].description.as_deref(),
Some("Row 1 Column 2")
);
}
#[test]
fn zero_rows_produces_only_headers() {
let params = make_params(0, &["A", "B"], &[]);
let root = build_table_a11y_tree(¶ms);
assert_eq!(root.children.len(), 2); for child in &root.children {
assert_eq!(child.role, A11yRole::ColumnHeader);
}
}
#[test]
fn zero_cols_produces_only_rows_no_cells() {
let params = make_params(3, &[], &[]);
let root = build_table_a11y_tree(¶ms);
let rows: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::TableRow)
.collect();
assert_eq!(rows.len(), 3);
for row in rows {
assert!(row.children.is_empty(), "rows with 0 cols have no cells");
}
}
#[test]
fn node_ids_are_unique_and_sequential() {
let params = make_params(2, &["A", "B"], &[]);
let root = build_table_a11y_tree(¶ms);
fn collect_ids(node: &LightNode, out: &mut Vec<u64>) {
out.push(node.id);
for child in &node.children {
collect_ids(child, out);
}
}
let mut ids = Vec::new();
collect_ids(&root, &mut ids);
let mut sorted = ids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "all node IDs must be unique");
}
#[test]
fn with_text_fills_cell_labels() {
let headers: &[&str] = &["Name", "Age"];
let selected: &[usize] = &[];
let cell_text: Vec<Vec<String>> = vec![
vec!["Alice".to_string(), "30".to_string()],
vec!["Bob".to_string(), "25".to_string()],
];
let params = TableA11yWithTextParams {
base: TableA11yParams {
row_count: 2,
col_headers: headers,
selected_rows: selected,
first_node_id: 1,
},
cell_text: &cell_text,
};
let root = build_table_a11y_with_text(¶ms);
let rows: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::TableRow)
.collect();
assert_eq!(rows[0].children[0].label.as_deref(), Some("Alice"));
assert_eq!(rows[0].children[1].label.as_deref(), Some("30"));
assert_eq!(rows[1].children[0].label.as_deref(), Some("Bob"));
assert_eq!(rows[1].children[1].label.as_deref(), Some("25"));
}
#[test]
fn column_header_description_contains_column_number() {
let params = make_params(0, &["Name", "City"], &[]);
let root = build_table_a11y_tree(¶ms);
let headers: Vec<_> = root
.children
.iter()
.filter(|n| n.role == A11yRole::ColumnHeader)
.collect();
assert!(
headers[0]
.description
.as_deref()
.unwrap_or("")
.contains("Column 1"),
"first header description must contain 'Column 1'"
);
assert!(
headers[1]
.description
.as_deref()
.unwrap_or("")
.contains("Column 2"),
"second header description must contain 'Column 2'"
);
}
#[cfg(feature = "a11y-table")]
#[test]
fn full_build_produces_correct_child_count() {
let root = build_table_a11y_full(2, 3, &["A", "B", "C"]);
assert_eq!(
root.children.len(),
5,
"expected 3 headers + 2 rows = 5 children"
);
}
#[cfg(feature = "a11y-table")]
#[test]
fn full_build_with_text_selected_row() {
let cell_text: Vec<Vec<String>> = vec![
vec!["A1".to_string(), "A2".to_string()],
vec!["B1".to_string(), "B2".to_string()],
];
let root = build_table_a11y_full_with_text(2, &["H1", "H2"], 2, &cell_text, &[1]);
let rows: Vec<_> = root
.children
.iter()
.filter(|n| n.role == WidgetRole::TableRow)
.collect();
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].props.selected, Some(true), "row 1 must be selected");
}
}