1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
//! WCAG 2.4.7 Focus Visible
//!
//! Any keyboard operable user interface has a mode of operation where the
//! keyboard focus indicator is visible.
//! Level AA
//!
//! Note: Full focus-visible checking requires CSS inspection via CDP — see
//! `focus_visible_css.rs` for the check that inspects `:focus` stylesheet
//! rules for suppressed outlines. This AX-tree-only rule is limited to the
//! degenerate case of a page with no focusable elements at all.
//!
//! An earlier version of this rule also flagged natively-focusable roles
//! (button/link/radio/…) carrying `tabindex="-1"` as removed from the tab
//! order. That check never fired against real CDP data — Chrome's
//! Accessibility tree does not expose a `tabindex` AX property (`focusable`
//! only reflects whether the element can receive focus at all, not whether
//! it is in the sequential tab order) — and, had it worked, it would have
//! misflagged the widely-recommended "roving tabindex" ARIA pattern (e.g.
//! WAI-ARIA APG radio groups and menus, where every non-active item
//! legitimately carries `tabindex="-1"`). Reachability via tabindex is
//! already covered under 2.1.1 by `keyboard.rs`/`click_handlers.rs`, so the
//! dead branch was removed rather than reimplemented here.
use crate::accessibility::AXTree;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub(super) const FOCUS_VISIBLE_RULE: RuleMetadata = RuleMetadata {
id: "2.4.7",
name: "Focus Visible",
level: WcagLevel::AA,
severity: Severity::High,
description: "Any keyboard operable user interface has a visible focus indicator",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html",
axe_id: "focus-visible",
tags: &["wcag2aa", "wcag247", "cat.keyboard"],
};
pub fn check_focus_visible(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
let mut focusable_count = 0u32;
for node in tree.iter() {
if node.ignored {
continue;
}
// The document root itself always carries `focusable: true` in real
// CDP output (confirmed live) — it's Chrome's own fallback focus
// target, not a real interactive element a keyboard user would tab
// to, so counting it made this "page has zero focusable elements"
// check permanently unreachable for every real page (#568).
if node.role.as_deref() == Some("RootWebArea") {
continue;
}
// Check focusable interactive elements
if !node.is_interactive() && !node.is_focusable() {
continue;
}
focusable_count += 1;
}
// No focusable elements at all: 2.4.7 is not applicable — it concerns
// "any keyboard operable user interface", and there is none. Kept as a
// review hint, not a violation, because a page with nothing to focus is
// unusual enough to be worth a look (e.g. controls built from plain
// elements, which is a 2.1.1 question, not a focus-visibility one).
if focusable_count == 0 && tree.len() > 5 {
let violation = Violation::new(
FOCUS_VISIBLE_RULE.id,
FOCUS_VISIBLE_RULE.name,
FOCUS_VISIBLE_RULE.level,
Severity::Low,
"Page has no focusable interactive elements",
"root",
)
.with_selector("root")
.with_fix("Ensure interactive elements are keyboard focusable")
.with_help_url(FOCUS_VISIBLE_RULE.help_url)
.with_rule_id(FOCUS_VISIBLE_RULE.axe_id)
.as_warning();
results.add_violation(violation);
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue};
fn interactive_node(id: &str, role: &str, tabindex: Option<i64>) -> AXNode {
let mut properties = vec![AXProperty {
name: "focusable".to_string(),
value: AXValue::Bool(tabindex != Some(-1)),
}];
if let Some(ti) = tabindex {
properties.push(AXProperty {
name: "tabindex".to_string(),
value: AXValue::Int(ti),
});
}
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: Some("Test".to_string()),
name_source: None,
description: None,
value: None,
properties,
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn test_focusable_buttons_pass() {
let tree = AXTree::from_nodes(vec![
interactive_node("1", "button", Some(0)),
interactive_node("2", "link", None),
]);
let results = check_focus_visible(&tree);
assert_eq!(results.violations.len(), 0);
}
/// Regression guard: `tabindex="-1"` on a normally-focusable role must
/// NOT be flagged. This used to raise a "removed from tab order"
/// violation, but that check read an AX `tabindex` property Chrome's
/// CDP Accessibility tree never actually populates (so it was dead code
/// in production), and — had it worked — it would have misflagged the
/// widely-recommended "roving tabindex" ARIA pattern (e.g. radio groups
/// and menus, where every non-active item legitimately carries
/// `tabindex="-1"`). See module docs.
#[test]
fn test_tabindex_minus_one_is_not_flagged() {
let tree = AXTree::from_nodes(vec![
interactive_node("1", "radio", Some(0)),
interactive_node("2", "radio", Some(-1)),
interactive_node("3", "radio", Some(-1)),
]);
let results = check_focus_visible(&tree);
assert_eq!(results.violations.len(), 0);
}
fn non_interactive_node(id: &str, role: &str) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: Some("Test".to_string()),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
/// #568: the document root always carries `focusable: true` in real CDP
/// output — it must not itself count as "the page has a focusable
/// element", or this check can never fire for any real page. What fires
/// is a review hint, not a violation: without a keyboard-operable
/// interface 2.4.7 is not applicable.
#[test]
fn test_root_web_area_focusable_does_not_count_as_a_focusable_element() {
let mut root = interactive_node("1", "RootWebArea", None);
root.role = Some("RootWebArea".to_string());
let tree = AXTree::from_nodes(vec![
root,
non_interactive_node("2", "heading"),
non_interactive_node("3", "paragraph"),
non_interactive_node("4", "paragraph"),
non_interactive_node("5", "generic"),
non_interactive_node("6", "generic"),
]);
let results = check_focus_visible(&tree);
assert!(results.violations.is_empty());
assert_eq!(results.warnings.len(), 1);
}
}