1use crate::core::{Element, Node};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum Role {
16 Banner,
18 Navigation,
20 Main,
22 Complementary,
24 Contentinfo,
26 Search,
28 Form,
30 Region,
32 Button,
34 Dialog,
36 Alert,
38 Status,
40 Custom(String),
42}
43
44impl Role {
45 #[must_use]
47 pub fn as_str(&self) -> &str {
48 match self {
49 Self::Banner => "banner",
50 Self::Navigation => "navigation",
51 Self::Main => "main",
52 Self::Complementary => "complementary",
53 Self::Contentinfo => "contentinfo",
54 Self::Search => "search",
55 Self::Form => "form",
56 Self::Region => "region",
57 Self::Button => "button",
58 Self::Dialog => "dialog",
59 Self::Alert => "alert",
60 Self::Status => "status",
61 Self::Custom(role) => role,
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct A11yIssue {
70 pub tag: String,
72 pub message: String,
74}
75
76#[must_use]
101pub fn audit(node: &Node) -> Vec<A11yIssue> {
102 let mut issues = Vec::new();
103 walk(node, &mut issues);
104 issues
105}
106
107fn walk(root: &Node, issues: &mut Vec<A11yIssue>) {
110 let mut pending = vec![root];
111 while let Some(node) = pending.pop() {
112 match node {
113 Node::Element(element) => {
114 check(element, issues);
115 pending.extend(element.children().iter().rev());
116 }
117 Node::Fragment(children) => pending.extend(children.iter().rev()),
118 Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
119 }
120 }
121}
122
123fn check(element: &Element, issues: &mut Vec<A11yIssue>) {
124 let has = |key: &str| element.attributes().iter().any(|a| a.key() == key);
125 let mut report = |message: &str| {
126 issues.push(A11yIssue {
127 tag: element.tag().to_string(),
128 message: message.to_string(),
129 });
130 };
131
132 match element.tag() {
133 "img" if !has("alt") => {
134 report("an image needs an alt attribute; pass an empty one if it is decorative");
135 }
136 "iframe" if !has("title") => {
137 report("an iframe needs a title describing its content");
138 }
139 "button" if !has_accessible_name(element) => {
140 report("a button needs text content or an aria-label");
141 }
142 "a" if !has_accessible_name(element) => {
143 report("a link needs text content or an aria-label");
144 }
145 "input" if !has("aria-label") && !has("id") && !has("aria-labelledby") => {
146 report("an input needs an id a label can point at, or an aria-label");
147 }
148 _ => {}
149 }
150}
151
152fn has_accessible_name(element: &Element) -> bool {
154 if element
155 .attributes()
156 .iter()
157 .any(|a| a.key() == "aria-label" || a.key() == "aria-labelledby")
158 {
159 return true;
160 }
161 if element.content().is_some_and(|c| !c.trim().is_empty()) {
162 return true;
163 }
164 element.children().iter().any(|child| !child.is_empty())
165}
166
167pub fn debug_assert_accessible(node: &Node) {
174 if cfg!(debug_assertions) {
175 let issues = audit(node);
176 assert!(
177 issues.is_empty(),
178 "accessibility audit found {} issue(s): {:?}",
179 issues.len(),
180 issues
181 );
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::elements::{a, button, div, iframe, iframe_titled, image, img, input, link_to};
189
190 #[test]
191 fn an_image_without_alt_is_reported() {
192 let issues = audit(&img().attr("src", "/a.png").into());
193 assert_eq!(issues.len(), 1);
194 assert_eq!(issues[0].tag, "img");
195 }
196
197 #[test]
198 fn an_image_with_an_empty_alt_is_accepted_as_decorative() {
199 assert_eq!(audit(&image("/a.png", "").into()), Vec::new());
200 }
201
202 #[test]
203 fn an_iframe_without_a_title_is_reported() {
204 assert_eq!(audit(&iframe().attr("src", "/e").into()).len(), 1);
205 assert_eq!(audit(&iframe_titled("/e", "A map").into()), Vec::new());
206 }
207
208 #[test]
209 fn a_button_needs_text_or_a_label() {
210 assert_eq!(audit(&button().into()).len(), 1);
211 assert_eq!(audit(&button().text("Send").into()), Vec::new());
212 assert_eq!(
213 audit(&button().aria_attr("label", "Send").into()),
214 Vec::new()
215 );
216 }
217
218 #[test]
219 fn a_link_needs_an_accessible_name() {
220 assert_eq!(audit(&a().attr("href", "/x").into()).len(), 1);
221 assert_eq!(audit(&link_to("/x").text("Home").into()), Vec::new());
222 assert_eq!(
223 audit(&link_to("/x").child(image("/i.png", "Home")).into()),
224 Vec::new()
225 );
226 }
227
228 #[test]
229 fn an_input_needs_something_a_label_can_attach_to() {
230 assert_eq!(audit(&input().attr("type", "email").into()).len(), 1);
231 assert_eq!(audit(&input().attr("id", "email").into()), Vec::new());
232 assert_eq!(
233 audit(&input().aria_attr("label", "E-mail").into()),
234 Vec::new()
235 );
236 }
237
238 #[test]
239 fn the_audit_descends_into_children_and_fragments() {
240 let tree = div().child(div().child(img().attr("src", "/a.png")));
241 assert_eq!(audit(&tree.into()).len(), 1);
242
243 let fragment = Node::fragment([img().attr("src", "/a.png").into(), button().into()]);
244 assert_eq!(audit(&fragment).len(), 2);
245 }
246
247 #[test]
248 fn a_clean_page_reports_nothing() {
249 let tree = div()
250 .child(image("/a.png", "A cat"))
251 .child(button().text("Send"))
252 .child(link_to("/x").text("Home"));
253 assert_eq!(audit(&tree.into()), Vec::new());
254 }
255
256 #[test]
257 fn roles_render_their_attribute_value() {
258 assert_eq!(Role::Navigation.as_str(), "navigation");
259 assert_eq!(Role::Custom("tooltip".into()).as_str(), "tooltip");
260 }
261}