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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
//! WCAG 1.3.1 - Label Title Only
//!
//! axe-core rule: `label-title-only`
//! Form inputs that derive their accessible name solely from a `title`
//! attribute are flagged because `title` is only exposed on hover and is
//! not reliably announced by all assistive technologies.
//!
//! A visible `<label>`, `aria-label`, or `aria-labelledby` should be used
//! instead of (or in addition to) title.
use crate::accessibility::{AXTree, NameSource};
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub(super) const RULE_LABEL_TITLE_ONLY: RuleMetadata = RuleMetadata {
id: "1.3.1",
name: "Label Title Only",
level: WcagLevel::A,
severity: Severity::Medium,
description:
"Form elements must not rely solely on the title attribute for their accessible name",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
axe_id: "label-title-only",
tags: &["wcag2a", "wcag131", "cat.forms"],
};
/// Form input roles that should have a proper label (not just title).
const INPUT_ROLES: &[&str] = &[
"textbox",
"combobox",
"listbox",
"slider",
"spinbutton",
"searchbox",
];
/// Check form inputs for title-only labelling.
pub fn check_label_title_only(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.to_lowercase(),
None => continue,
};
if !INPUT_ROLES.contains(&role.as_str()) {
continue;
}
// If the node has a name and its source is Title, flag it.
// Also use a pragmatic fallback: if name_source is unavailable,
// check whether the node has a "title" property whose value
// matches the accessible name, and no aria-labelledby.
//
// (This fallback used to also check for "label"/"aria-label"
// properties as a way to rule out aria-label being the real name
// source — neither is a real CDP AX property name, so that check was
// an always-true no-op. Removed rather than fixed: when
// name_source is genuinely unavailable, there's no name_source-based
// signal left to distinguish an aria-label-driven name from a
// title-driven one; see #QA-032.)
let is_title_only = if node.name_source == Some(NameSource::Title) {
true
} else if node.has_name() && node.name_source.is_none() {
// Fallback heuristic
let title_val = node.get_property_str("title");
// `has_property("labelledby")` is the real (unprefixed) CDP name;
// the aria-prefixed form is never emitted (#QA-032).
let has_aria_labelledby = node.has_property("labelledby");
if let (Some(name), Some(title)) = (node.name.as_deref(), title_val) {
name == title && !has_aria_labelledby
} else {
false
}
} else {
false
};
if is_title_only {
results.add_violation(
Violation::new(
RULE_LABEL_TITLE_ONLY.id,
RULE_LABEL_TITLE_ONLY.name,
RULE_LABEL_TITLE_ONLY.level,
RULE_LABEL_TITLE_ONLY.severity,
format!(
"Form element with role=\"{}\" derives its accessible name only from the title attribute",
role
),
&node.node_id,
)
.with_role(node.role.clone())
.with_fix(
"Add a visible <label> element or aria-label instead of relying on title attribute",
)
.with_rule_id(RULE_LABEL_TITLE_ONLY.axe_id)
.with_help_url(RULE_LABEL_TITLE_ONLY.help_url),
);
} else if node.has_name() {
results.passes += 1;
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue, NameSource};
fn input_node(
id: &str,
role: &str,
name: Option<&str>,
name_source: Option<NameSource>,
props: Vec<(&str, &str)>,
) -> AXNode {
AXNode {
node_id: id.into(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.into()),
name: name.map(String::from),
name_source,
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: None,
backend_dom_node_id: None,
}
}
#[test]
fn test_title_only_name_source_flagged() {
let node = input_node(
"1",
"textbox",
Some("Search"),
Some(NameSource::Title),
vec![],
);
let tree = AXTree::from_nodes(vec![node]);
let r = check_label_title_only(&tree);
assert!(r
.violations
.iter()
.any(|v| v.rule_id.as_deref() == Some("label-title-only")));
}
#[test]
fn test_aria_label_name_source_passes() {
let node = input_node(
"1",
"textbox",
Some("Search"),
Some(NameSource::Attribute),
vec![],
);
let tree = AXTree::from_nodes(vec![node]);
let r = check_label_title_only(&tree);
assert!(r.violations.is_empty());
assert!(r.passes >= 1);
}
#[test]
fn test_label_name_source_passes() {
let node = input_node(
"1",
"combobox",
Some("Country"),
Some(NameSource::RelatedElement),
vec![],
);
let tree = AXTree::from_nodes(vec![node]);
let r = check_label_title_only(&tree);
assert!(r.violations.is_empty());
assert!(r.passes >= 1);
}
#[test]
fn test_fallback_heuristic_title_matches_name_flagged() {
// No name_source, but title property matches the name
let node = input_node(
"1",
"searchbox",
Some("Search site"),
None,
vec![("title", "Search site")],
);
let tree = AXTree::from_nodes(vec![node]);
let r = check_label_title_only(&tree);
assert!(r
.violations
.iter()
.any(|v| v.rule_id.as_deref() == Some("label-title-only")));
}
// A previous test here asserted that a "label"/"aria-label" AX property
// suppressed the title-only fallback heuristic. Neither is a real CDP
// property (#QA-032) — the test was self-consistent (its own fixture
// used the same made-up property name the code checked) but never
// reflected real Chrome behavior. Removed along with the dead check.
#[test]
fn test_non_input_role_not_flagged() {
let node = input_node(
"1",
"button",
Some("Submit"),
Some(NameSource::Title),
vec![],
);
let tree = AXTree::from_nodes(vec![node]);
let r = check_label_title_only(&tree);
assert!(r.violations.is_empty());
}
#[test]
fn test_slider_title_only_flagged() {
let node = input_node(
"1",
"slider",
Some("Volume"),
Some(NameSource::Title),
vec![],
);
let tree = AXTree::from_nodes(vec![node]);
let r = check_label_title_only(&tree);
assert!(r
.violations
.iter()
.any(|v| v.rule_id.as_deref() == Some("label-title-only")));
}
}