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