use crate::accessibility::{AXNode, AXTree, AXValue};
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub const RULE_META: RuleMetadata = RuleMetadata {
id: "2.4.6",
name: "Headings and Labels",
level: WcagLevel::AA,
severity: Severity::Medium,
description: "Headings and labels describe topic or purpose",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels.html",
axe_id: "heading-order",
tags: &["wcag2aa", "wcag246", "best-practice"],
};
pub fn check_headings(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
let headings = tree.headings();
results.nodes_checked = headings.len();
for heading in &headings {
if heading.ignored {
continue;
}
check_empty_heading(heading, &mut results);
}
check_heading_hierarchy(&headings, &mut results);
check_multiple_h1(&headings, &mut results);
check_missing_h1(&headings, &mut results);
results
}
fn check_empty_heading(heading: &AXNode, results: &mut WcagResults) {
if !heading.has_name() {
let level = get_heading_level(heading).unwrap_or(0);
let violation = Violation::new(
RULE_META.id,
RULE_META.name,
RULE_META.level,
Severity::High,
format!("Heading level {} is empty", level),
&heading.node_id,
)
.with_role(heading.role.clone())
.with_fix("Add descriptive text content to the heading")
.with_help_url(RULE_META.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
}
}
fn check_heading_hierarchy(headings: &[&AXNode], results: &mut WcagResults) {
let mut sorted_headings: Vec<_> = headings
.iter()
.filter(|h| !h.ignored)
.filter_map(|h| get_heading_level(h).map(|level| (level, *h)))
.collect();
sorted_headings.sort_by(|a, b| a.1.node_id.cmp(&b.1.node_id));
let mut prev_level: Option<u8> = None;
for (level, heading) in sorted_headings {
if let Some(prev) = prev_level {
if level > prev + 1 {
let violation = Violation::new(
"1.3.1", "Heading Hierarchy",
WcagLevel::A,
Severity::Medium,
format!(
"Heading level skipped from h{} to h{} (should not skip levels)",
prev, level
),
&heading.node_id,
)
.with_role(heading.role.clone())
.with_name(heading.name.clone())
.with_fix(format!(
"Use h{} instead of h{}, or add intermediate headings",
prev + 1,
level
))
.with_help_url(
"https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html",
);
results.add_violation(violation);
}
}
prev_level = Some(level);
}
}
fn check_multiple_h1(headings: &[&AXNode], results: &mut WcagResults) {
let h1_headings: Vec<_> = headings
.iter()
.filter(|h| !h.ignored)
.filter(|h| get_heading_level(h) == Some(1))
.collect();
if h1_headings.len() > 1 {
for (i, heading) in h1_headings.iter().enumerate().skip(1) {
let violation = Violation::new(
RULE_META.id,
RULE_META.name,
RULE_META.level,
Severity::Low,
format!(
"Multiple h1 elements found (this is h1 #{}, best practice is to have only one)",
i + 1
),
&heading.node_id,
)
.with_role(heading.role.clone())
.with_name(heading.name.clone())
.with_fix("Consider using h2 or lower for secondary main headings")
.with_help_url(RULE_META.help_url);
results.add_violation(violation);
}
}
}
fn check_missing_h1(headings: &[&AXNode], results: &mut WcagResults) {
let has_h1 = headings
.iter()
.any(|h| !h.ignored && get_heading_level(h) == Some(1));
if !has_h1 && !headings.is_empty() {
if let Some(first_heading) = headings.first() {
let violation = Violation::new(
RULE_META.id,
RULE_META.name,
RULE_META.level,
Severity::Low,
"Page is missing an h1 element (main heading)",
&first_heading.node_id,
)
.with_fix("Add an h1 element as the main page heading")
.with_help_url(RULE_META.help_url);
results.add_violation(violation);
}
}
}
fn get_heading_level(node: &AXNode) -> Option<u8> {
if node.role.as_deref() != Some("heading") {
return None;
}
node.properties
.iter()
.find(|p| p.name == "level")
.and_then(|p| match &p.value {
AXValue::Int(i) => Some((*i).clamp(1, 6) as u8),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::AXProperty;
fn create_heading_node(id: &str, level: u8, name: Option<&str>) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some("heading".to_string()),
name: name.map(String::from),
name_source: None,
description: None,
value: None,
properties: vec![AXProperty {
name: "level".to_string(),
value: AXValue::Int(level as i64),
}],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn test_valid_heading_hierarchy() {
let nodes = vec![
create_heading_node("1", 1, Some("Main Title")),
create_heading_node("2", 2, Some("Section")),
create_heading_node("3", 3, Some("Subsection")),
];
let tree = AXTree::from_nodes(nodes);
let results = check_headings(&tree);
let hierarchy_violations: Vec<_> = results
.violations
.iter()
.filter(|v| v.message.contains("skipped"))
.collect();
assert!(hierarchy_violations.is_empty());
}
#[test]
fn test_skipped_heading_level() {
let nodes = vec![
create_heading_node("1", 1, Some("Main Title")),
create_heading_node("2", 4, Some("Skipped to h4")), ];
let tree = AXTree::from_nodes(nodes);
let results = check_headings(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("skipped")));
}
#[test]
fn test_empty_heading() {
let nodes = vec![create_heading_node("1", 1, None)];
let tree = AXTree::from_nodes(nodes);
let results = check_headings(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("empty")));
}
#[test]
fn test_multiple_h1() {
let nodes = vec![
create_heading_node("1", 1, Some("First H1")),
create_heading_node("2", 1, Some("Second H1")),
];
let tree = AXTree::from_nodes(nodes);
let results = check_headings(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("Multiple h1")));
}
#[test]
fn test_missing_h1() {
let nodes = vec![
create_heading_node("1", 2, Some("Section")),
create_heading_node("2", 3, Some("Subsection")),
];
let tree = AXTree::from_nodes(nodes);
let results = check_headings(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("missing an h1")));
}
}