use chromiumoxide::Page;
use crate::accessibility::{AXNode, AXTree, AXValue};
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub(super) const RULE_TD_HEADERS_ATTR: RuleMetadata = RuleMetadata {
id: "1.3.1",
name: "Table Cell Headers Attribute",
level: WcagLevel::A,
severity: Severity::High,
description: "Each cell that uses the headers attribute must only refer to valid header cells in the same table",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
axe_id: "td-headers-attr",
tags: &["wcag2a", "wcag131", "cat.tables"],
};
const TD_HEADERS_ATTR_CAP: usize = 250;
const TD_HEADERS_ATTR_BODY: &str = r#"
var issues = [];
var cells = document.querySelectorAll('[headers]');
for (var i = 0; i < cells.length && __amsReal(issues) < CAP; i++) {
var cell = cells[i];
var table = cell.closest('table, [role="grid"], [role="table"], [role="treegrid"]');
var refs = (cell.getAttribute('headers') || '').trim().split(/\s+/).filter(Boolean);
if (refs.length === 0) {
__amsPush(issues, cell, { selector: __amsCssSelector(cell), kind: 'empty' }, CAP);
continue;
}
var invalidRef = null;
for (var r = 0; r < refs.length; r++) {
var ref = document.getElementById(refs[r]);
var tag = ref ? ref.tagName : null;
var role = ref ? (ref.getAttribute('role') || '').toLowerCase() : '';
var isHeaderCell = tag === 'TH' || role === 'columnheader' || role === 'rowheader';
if (!ref || (table && !table.contains(ref)) || !isHeaderCell) {
invalidRef = refs[r];
break;
}
}
if (invalidRef) {
__amsPush(issues, cell, { selector: __amsCssSelector(cell), kind: 'invalid', ref: invalidRef }, CAP);
}
}
return { issues: issues };
"#;
pub async fn check_table_headers_attr_with_page(page: &Page) -> Vec<Violation> {
let js = [
"(function() {",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
&TD_HEADERS_ATTR_BODY.replace("CAP", &TD_HEADERS_ATTR_CAP.to_string()),
"})()",
]
.concat();
let val = match crate::wcag::types::evaluate_or_fail_for(
page,
"td-headers-attr",
crate::cli::WcagLevel::A,
js.as_str(),
)
.await
{
Ok(v) => v,
Err(violations) => return violations,
};
let issues = match val.get("issues").and_then(|v| v.as_array()) {
Some(arr) => arr.clone(),
None => return vec![],
};
issues
.iter()
.filter_map(|issue| {
let selector = issue.get("selector")?.as_str()?.to_string();
let kind = issue.get("kind")?.as_str()?;
let (message, fix) = if kind == "empty" {
(
"Table cell has an empty headers attribute".to_string(),
"Remove the headers attribute or reference valid th/header cell IDs",
)
} else {
let r#ref = issue.get("ref").and_then(|v| v.as_str()).unwrap_or("");
(
format!(
"Table cell references header ID \"{}\" which does not exist in this table",
r#ref
),
"Ensure the headers attribute only references IDs of <th> elements in the same table",
)
};
Some(
Violation::new(
RULE_TD_HEADERS_ATTR.id,
RULE_TD_HEADERS_ATTR.name,
RULE_TD_HEADERS_ATTR.level,
RULE_TD_HEADERS_ATTR.severity,
message,
selector.clone(),
)
.with_selector(selector)
.with_fix(fix)
.with_rule_id(RULE_TD_HEADERS_ATTR.axe_id)
.with_help_url(RULE_TD_HEADERS_ATTR.help_url),
)
})
.collect()
}
pub(super) const RULE_TH_HAS_DATA_CELLS: RuleMetadata = RuleMetadata {
id: "1.3.1",
name: "Header Cell Has Data Cells",
level: WcagLevel::A,
severity: Severity::High,
description: "Each table header must have associated data cells",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
axe_id: "th-has-data-cells",
tags: &["wcag2a", "wcag131", "cat.tables"],
};
pub fn check_table_extended(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
for node in tree.iter() {
if node.ignored {
continue;
}
let role = match node.role.as_deref() {
Some(r) => r.to_lowercase(),
None => continue,
};
if role != "grid" && role != "table" && role != "treegrid" {
continue;
}
results.nodes_checked += 1;
let mut cells = TableCells::default();
collect_table_cells(tree, node, &mut cells, true, 0);
let TableCells {
header_cells,
data_cells,
unrendered_rows,
} = cells;
if !header_cells.is_empty() {
if data_cells.is_empty() && unrendered_rows {
results.incomplete += header_cells.len();
} else if data_cells.is_empty() {
for hcell in &header_cells {
results.add_violation(
Violation::new(
RULE_TH_HAS_DATA_CELLS.id,
RULE_TH_HAS_DATA_CELLS.name,
RULE_TH_HAS_DATA_CELLS.level,
RULE_TH_HAS_DATA_CELLS.severity,
"Table header cell has no associated data cells in the same table",
&hcell.node_id,
)
.with_role(hcell.role.clone())
.with_fix("Ensure the table contains <td> data cells that correspond to this header, or convert this header to a data cell if no data relationship exists")
.with_rule_id(RULE_TH_HAS_DATA_CELLS.axe_id).with_help_url(RULE_TH_HAS_DATA_CELLS.help_url),
);
}
} else {
results.passes += header_cells.len();
}
}
}
results
}
const MAX_DEPTH: usize = 12;
#[derive(Default)]
struct TableCells<'a> {
header_cells: Vec<&'a AXNode>,
data_cells: Vec<&'a AXNode>,
unrendered_rows: bool,
}
fn collect_table_cells<'a>(
tree: &'a AXTree,
node: &'a AXNode,
cells: &mut TableCells<'a>,
row_level: bool,
depth: usize,
) {
if depth > MAX_DEPTH {
return;
}
for child_id in &node.child_ids {
if let Some(child) = tree.get_node(child_id) {
if child.ignored {
if row_level && is_not_rendered(child) {
cells.unrendered_rows = true;
}
collect_table_cells(tree, child, cells, row_level, depth + 1);
continue;
}
match child.role.as_deref() {
Some("columnheader") | Some("rowheader") => cells.header_cells.push(child),
Some("gridcell") | Some("cell") => cells.data_cells.push(child),
_ => {}
}
let child_row_level = child.role.as_deref() == Some("rowgroup");
collect_table_cells(tree, child, cells, child_row_level, depth + 1);
}
}
}
fn is_not_rendered(node: &AXNode) -> bool {
node.ignored_reasons
.iter()
.any(|r| r.name == "notRendered" && matches!(r.value, AXValue::Bool(true)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue};
fn node(id: &str, role: &str, parent: Option<&str>, props: Vec<(&str, &str)>) -> AXNode {
AXNode {
node_id: id.into(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.into()),
name: None,
name_source: None,
description: None,
value: None,
properties: props
.into_iter()
.map(|(k, v)| AXProperty {
name: k.into(),
value: AXValue::String(v.into()),
})
.collect(),
child_ids: vec![],
parent_id: parent.map(String::from),
backend_dom_node_id: None,
}
}
fn table_with_header_and_data() -> AXTree {
let mut table = node("t", "table", None, vec![]);
let header = node("h1", "columnheader", Some("t"), vec![]);
let data = node("d1", "gridcell", Some("t"), vec![]);
table.child_ids = vec!["h1".into(), "d1".into()];
AXTree::from_nodes(vec![table, header, data])
}
#[test]
fn test_header_with_data_cells_passes() {
let tree = table_with_header_and_data();
let r = check_table_extended(&tree);
assert!(r.violations.is_empty(), "header + data should pass");
}
#[test]
fn test_header_without_data_cells_flagged() {
let mut table = node("t", "table", None, vec![]);
let header = node("h1", "columnheader", Some("t"), vec![]);
table.child_ids = vec!["h1".into()];
let tree = AXTree::from_nodes(vec![table, header]);
let r = check_table_extended(&tree);
assert!(
r.violations
.iter()
.any(|v| v.rule_id.as_deref() == Some("th-has-data-cells")),
"header without data cells should be flagged"
);
}
#[test]
fn test_data_cells_under_ignored_tbody_pass() {
let mut table = node("t", "table", None, vec![]);
let mut thead = node("thead", "rowgroup", Some("t"), vec![]);
let mut hrow = node("hr", "row", Some("thead"), vec![]);
let header = node("h1", "columnheader", Some("hr"), vec![]);
let mut tbody = node("tbody", "none", Some("t"), vec![]);
tbody.ignored = true;
let mut brow = node("br", "row", Some("tbody"), vec![]);
let rowheader = node("rh", "rowheader", Some("br"), vec![]);
let data = node("d1", "cell", Some("br"), vec![]);
table.child_ids = vec!["thead".into(), "tbody".into()];
thead.child_ids = vec!["hr".into()];
hrow.child_ids = vec!["h1".into()];
tbody.child_ids = vec!["br".into()];
brow.child_ids = vec!["rh".into(), "d1".into()];
let tree = AXTree::from_nodes(vec![
table, thead, hrow, header, tbody, brow, rowheader, data,
]);
let r = check_table_extended(&tree);
assert!(
r.violations.is_empty(),
"data cells beneath an ignored tbody must count: {:?}",
r.violations
);
}
fn not_rendered(mut n: AXNode) -> AXNode {
n.ignored = true;
n.ignored_reasons = vec![AXProperty {
name: "notRendered".into(),
value: AXValue::Bool(true),
}];
n
}
#[test]
fn test_unrendered_body_rows_are_undetermined() {
let mut grid = node("g", "grid", None, vec![]);
let mut head = node("head", "rowgroup", Some("g"), vec![]);
let mut hrow = node("hr", "row", Some("head"), vec![]);
let h1 = node("h1", "columnheader", Some("hr"), vec![]);
let h2 = node("h2", "columnheader", Some("hr"), vec![]);
let mut body = node("body", "rowgroup", Some("g"), vec![]);
let mut pooled = not_rendered(node("r1", "none", Some("body"), vec![]));
let pooled_cell = not_rendered(node("c1", "none", Some("r1"), vec![]));
grid.child_ids = vec!["head".into(), "body".into()];
head.child_ids = vec!["hr".into()];
hrow.child_ids = vec!["h1".into(), "h2".into()];
body.child_ids = vec!["r1".into()];
pooled.child_ids = vec!["c1".into()];
let tree = AXTree::from_nodes(vec![grid, head, hrow, h1, h2, body, pooled, pooled_cell]);
let r = check_table_extended(&tree);
assert!(r.violations.is_empty(), "{:?}", r.violations);
assert_eq!(r.incomplete, 2);
}
#[test]
fn test_hidden_content_inside_header_cell_still_flagged() {
let mut table = node("t", "table", None, vec![]);
let mut row = node("r", "row", Some("t"), vec![]);
let mut header = node("h1", "columnheader", Some("r"), vec![]);
let icon = not_rendered(node("i", "none", Some("h1"), vec![]));
table.child_ids = vec!["r".into()];
row.child_ids = vec!["h1".into()];
header.child_ids = vec!["i".into()];
let tree = AXTree::from_nodes(vec![table, row, header, icon]);
let r = check_table_extended(&tree);
assert_eq!(r.violations.len(), 1);
}
}