use chromiumoxide::Page;
use crate::accessibility::AXTree;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub(super) const RULE_META: RuleMetadata = RuleMetadata {
id: "4.1.2",
name: "ARIA Role Validity",
level: WcagLevel::A,
severity: Severity::High,
description: "ARIA roles must be valid and required owned elements must be present",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
axe_id: "aria-roles",
tags: &["wcag2a", "wcag412", "cat.aria"],
};
const REQUIRED_OWNED_ELEMENTS: &[(&str, &[&str])] = &[
("list", &["listitem"]),
("table", &["row", "rowgroup"]),
("grid", &["row", "rowgroup"]),
("treegrid", &["row", "rowgroup"]),
("menu", &["menuitem", "menuitemcheckbox", "menuitemradio"]),
(
"menubar",
&["menuitem", "menuitemcheckbox", "menuitemradio"],
),
("tree", &["treeitem"]),
("listbox", &["option"]),
("radiogroup", &["radio"]),
("tablist", &["tab"]),
];
pub fn check_aria_roles(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
for node in tree.iter() {
if node.ignored {
continue;
}
results.nodes_checked += 1;
let role = match node.role.as_deref() {
Some(r) => r,
None => continue,
};
check_required_owned_elements(node, role, tree, &mut results);
}
results
}
const INVALID_ROLE_CAP: usize = 250;
const VALID_DOM_ARIA_ROLES_JS: &str = r#"
var validRoles = [
'alert','alertdialog','application','article','banner','blockquote','button',
'caption','cell','checkbox','code','columnheader','combobox','complementary',
'contentinfo','definition','deletion','dialog','directory','document','emphasis',
'feed','figure','form','generic','grid','gridcell','group','heading','img',
'insertion','link','list','listbox','listitem','log','main','mark','marquee',
'math','menu','menubar','menuitem','menuitemcheckbox','menuitemradio','meter',
'navigation','none','note','option','paragraph','presentation','progressbar',
'radio','radiogroup','region','row','rowgroup','rowheader','scrollbar','search',
'searchbox','separator','slider','spinbutton','status','strong','subscript',
'superscript','switch','tab','table','tablist','tabpanel','term','textbox',
'time','timer','toolbar','tooltip','tree','treegrid','treeitem'
];
"#;
const INVALID_ROLE_BODY: &str = r#"
var issues = [];
var elems = document.querySelectorAll('[role]');
for (var i = 0; i < elems.length && __amsReal(issues) < CAP; i++) {
var el = elems[i];
var roles = (el.getAttribute('role') || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
for (var r = 0; r < roles.length; r++) {
if (validRoles.indexOf(roles[r]) === -1) {
__amsPush(issues, el, { role: roles[r], selector: __amsCssSelector(el) }, CAP);
break;
}
}
}
return { issues: issues };
"#;
pub async fn check_invalid_role_with_page(page: &Page) -> Vec<Violation> {
let js = [
"(function() {",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
VALID_DOM_ARIA_ROLES_JS,
&INVALID_ROLE_BODY.replace("CAP", &INVALID_ROLE_CAP.to_string()),
"})()",
]
.concat();
let val = match crate::wcag::types::evaluate_or_fail_for(
page,
"invalid-role",
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 role = issue.get("role")?.as_str()?;
let selector = issue.get("selector")?.as_str()?.to_string();
Some(
Violation::new(
RULE_META.id,
RULE_META.name,
RULE_META.level,
Severity::High,
format!("Element has invalid ARIA role: '{}'", role),
selector.clone(),
)
.with_selector(selector)
.with_fix("Use a valid ARIA role from the ARIA specification")
.with_rule_id(RULE_META.axe_id)
.with_help_url("https://www.w3.org/TR/wai-aria-1.2/#role_definitions"),
)
})
.collect()
}
const INVALID_ARIA_ATTR_NAME_AXE_ID: &str = "aria-attr-name-invalid";
const INVALID_ATTR_NAME_CAP: usize = 250;
const VALID_ARIA_ATTRIBUTES_JS: &str = r#"
var validAttrs = [
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls', 'aria-current',
'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled',
'aria-dropeffect', 'aria-errormessage', 'aria-expanded', 'aria-flowto', 'aria-grabbed',
'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label',
'aria-labelledby', 'aria-level', 'aria-live', 'aria-modal', 'aria-multiline',
'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-placeholder',
'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required',
'aria-roledescription', 'aria-rowcount', 'aria-rowindex', 'aria-rowindextext',
'aria-rowspan', 'aria-selected', 'aria-setsize', 'aria-sort', 'aria-valuemax',
'aria-valuemin', 'aria-valuenow', 'aria-valuetext'
];
"#;
const INVALID_ATTR_NAME_BODY: &str = r#"
var issues = [];
var elems = document.querySelectorAll('*');
for (var i = 0; i < elems.length && __amsReal(issues) < CAP; i++) {
var el = elems[i];
var attrs = el.attributes;
for (var a = 0; a < attrs.length; a++) {
var name = attrs[a].name;
if (name.indexOf('aria-') !== 0) continue;
if (validAttrs.indexOf(name) === -1) {
__amsPush(issues, el, { attr: name, selector: __amsCssSelector(el) }, CAP);
if (__amsReal(issues) >= CAP) break;
}
}
}
return { issues: issues };
"#;
pub async fn check_invalid_aria_attribute_name_with_page(page: &Page) -> Vec<Violation> {
let js = [
"(function() {",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
VALID_ARIA_ATTRIBUTES_JS,
&INVALID_ATTR_NAME_BODY.replace("CAP", &INVALID_ATTR_NAME_CAP.to_string()),
"})()",
]
.concat();
let val = match crate::wcag::types::evaluate_or_fail_for(
page,
"invalid-aria-attribute-name",
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 attr = issue.get("attr")?.as_str()?;
let selector = issue.get("selector")?.as_str()?.to_string();
Some(
Violation::new(
RULE_META.id,
RULE_META.name,
RULE_META.level,
Severity::High,
format!("Element has invalid ARIA attribute: '{}'", attr),
selector.clone(),
)
.with_selector(selector)
.with_fix("Use a valid aria-* attribute from the ARIA specification")
.with_rule_id(INVALID_ARIA_ATTR_NAME_AXE_ID)
.with_help_url("https://www.w3.org/TR/wai-aria-1.2/#state_prop_def"),
)
})
.collect()
}
fn check_required_owned_elements(
node: &crate::accessibility::AXNode,
role: &str,
tree: &AXTree,
results: &mut WcagResults,
) {
let required = REQUIRED_OWNED_ELEMENTS.iter().find(|(r, _)| *r == role);
let (_, required_child_roles) = match required {
Some(r) => r,
None => return,
};
let is_collapsed = node
.get_property_str("expanded")
.map(|v| v == "false")
.unwrap_or(false)
|| node.get_property_bool("expanded") == Some(false);
if is_collapsed {
return;
}
let has_required_child = has_owned_role(tree, &node.child_ids, required_child_roles, 0);
if !has_required_child && !node.child_ids.is_empty() {
let violation = Violation::new(
RULE_META.id,
"ARIA Required Owned Elements",
RULE_META.level,
Severity::High,
format!(
"Element with role '{}' is missing required child {}: {}",
role,
if required_child_roles.len() == 1 {
"role"
} else {
"roles"
},
required_child_roles.join(", ")
),
&node.node_id,
)
.with_role(node.role.clone())
.with_fix(format!(
"Add child elements with {}: {}",
if required_child_roles.len() == 1 {
"role"
} else {
"roles"
},
required_child_roles.join(", ")
))
.with_help_url("https://www.w3.org/TR/wai-aria-1.2/#mustContain")
.with_rule_id(RULE_META.axe_id);
results.add_violation(violation);
}
}
const MAX_OWNED_DEPTH: usize = 4;
fn has_owned_role(tree: &AXTree, child_ids: &[String], roles: &[&str], depth: usize) -> bool {
child_ids.iter().any(|child_id| {
let Some(child) = tree.nodes.get(child_id) else {
return false;
};
let matches = child
.role
.as_deref()
.is_some_and(|child_role| roles.contains(&child_role));
matches
|| (child.ignored
&& depth < MAX_OWNED_DEPTH
&& has_owned_role(tree, &child.child_ids, roles, depth + 1))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue};
fn make_node(id: &str, role: &str, parent_id: Option<&str>, child_ids: Vec<&str>) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: Some(format!("Node {}", id)),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: child_ids.into_iter().map(String::from).collect(),
parent_id: parent_id.map(String::from),
backend_dom_node_id: None,
}
}
#[test]
fn test_valid_role_passes() {
let nodes = vec![
make_node("1", "WebArea", None, vec!["2"]),
make_node("2", "button", Some("1"), vec![]),
];
let tree = AXTree::from_nodes(nodes);
let results = check_aria_roles(&tree);
assert_eq!(results.violations.len(), 0);
}
#[test]
fn test_list_without_listitem_flagged() {
let nodes = vec![
make_node("1", "WebArea", None, vec!["2"]),
make_node("2", "list", Some("1"), vec!["3"]),
make_node("3", "paragraph", Some("2"), vec![]),
];
let tree = AXTree::from_nodes(nodes);
let results = check_aria_roles(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("listitem")));
}
#[test]
fn test_table_rows_under_ignored_tbody_pass() {
let mut tbody = make_node("3", "none", Some("2"), vec!["4"]);
tbody.ignored = true;
let nodes = vec![
make_node("1", "WebArea", None, vec!["2"]),
make_node("2", "table", Some("1"), vec!["3"]),
tbody,
make_node("4", "row", Some("3"), vec!["5"]),
make_node("5", "cell", Some("4"), vec![]),
];
let results = check_aria_roles(&AXTree::from_nodes(nodes));
assert!(
!results
.violations
.iter()
.any(|v| v.message.contains("missing required child")),
"rows beneath an ignored tbody must count: {:?}",
results.violations
);
}
#[test]
fn test_table_without_rows_still_flagged() {
let mut tbody = make_node("3", "none", Some("2"), vec![]);
tbody.ignored = true;
let nodes = vec![
make_node("1", "WebArea", None, vec!["2"]),
make_node("2", "table", Some("1"), vec!["4", "3"]),
make_node("4", "caption", Some("2"), vec![]),
tbody,
];
let results = check_aria_roles(&AXTree::from_nodes(nodes));
assert!(results.violations.iter().any(|v| v
.message
.contains("missing required child roles: row, rowgroup")));
}
#[test]
fn test_collapsed_menu_not_flagged() {
let mut menu = make_node("2", "menu", Some("1"), vec![]);
menu.properties.push(AXProperty {
name: "expanded".to_string(),
value: AXValue::Bool(false),
});
let nodes = vec![make_node("1", "WebArea", None, vec!["2"]), menu];
let tree = AXTree::from_nodes(nodes);
let results = check_aria_roles(&tree);
assert!(
!results
.violations
.iter()
.any(|v| v.message.contains("missing required child")),
"Collapsed menu should not be flagged for missing children"
);
}
}