Skip to main content

molt/
accessibility.rs

1//! Accessibility helpers and a debug-time audit.
2//!
3//! Beyond the attribute helpers (`aria_attr`, `set_role`) and the two constructors that make a
4//! mistake hard (`iframe_titled` requires a title, `image` takes an `alt`), this module is a
5//! linter: [`audit`] finds the accessibility problems that are visible in the tree.
6
7use crate::core::{Element, Node};
8
9/// A landmark or widget role, for [`Element::set_role`](crate::Element::set_role).
10///
11/// `set_role` accepts any string. This enum catches the typos while
12/// [`Role::Custom`] keeps the door open for roles it does not know.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum Role {
16    /// `role="banner"` — the page header.
17    Banner,
18    /// `role="navigation"`.
19    Navigation,
20    /// `role="main"`.
21    Main,
22    /// `role="complementary"` — a sidebar.
23    Complementary,
24    /// `role="contentinfo"` — the page footer.
25    Contentinfo,
26    /// `role="search"`.
27    Search,
28    /// `role="form"`.
29    Form,
30    /// `role="region"`.
31    Region,
32    /// `role="button"`.
33    Button,
34    /// `role="dialog"`.
35    Dialog,
36    /// `role="alert"`.
37    Alert,
38    /// `role="status"`.
39    Status,
40    /// Any other role, passed through verbatim.
41    Custom(String),
42}
43
44impl Role {
45    /// The attribute value for this role.
46    #[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/// Something in the tree that will be hard or impossible to use with assistive technology.
67#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct A11yIssue {
70    /// The tag the problem was found on.
71    pub tag: String,
72    /// What is wrong, in one sentence.
73    pub message: String,
74}
75
76/// Finds accessibility problems in a subtree.
77///
78/// The rules:
79///
80/// - `<img>` without an `alt` attribute
81/// - `<button>` with neither text nor an `aria-label`
82/// - `<iframe>` without a `title`
83/// - `<a>` with no accessible name
84/// - `<input>` with neither an `aria-label` nor an `id` a `<label>` could point at
85///
86/// This is a linter, not a guarantee: it cannot tell whether an `alt` is *useful*, only
87/// whether it exists.
88///
89/// # Examples
90/// ```
91/// use molt::prelude::*;
92/// use molt::accessibility::audit;
93///
94/// let good = image("/a.png", "A cat");
95/// assert_eq!(audit(&good.clone().into()), Vec::new());
96///
97/// let bad = Node::from(img().attr("src", "/a.png"));
98/// assert_eq!(audit(&bad).len(), 1);
99/// ```
100#[must_use]
101pub fn audit(node: &Node) -> Vec<A11yIssue> {
102    let mut issues = Vec::new();
103    walk(node, &mut issues);
104    issues
105}
106
107/// Pre-order, with an explicit stack: the renderer is iterative so that a 100,000-level tree
108/// is safe to render, and an audit that recursed would abort the process on the same tree.
109fn 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
152/// Whether an element has a name a screen reader can announce.
153fn 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
167/// Panics in debug builds if the subtree has accessibility problems.
168///
169/// Compiled away entirely in release builds, so it costs nothing in production.
170///
171/// # Panics
172/// In debug builds, if [`audit`] reports any issue.
173pub 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}