Skip to main content

winged_rust/
accessibility.rs

1//! Accessibility helpers and a debug-time audit.
2//!
3//! Winged-Swift has no accessibility module: its whole a11y surface is `ariaAttribute`,
4//! `ariaAttributes` and `setRole` in `AttributeHelpers.swift`, plus two structural
5//! affordances — `Iframe` requires a `title:`, and `Img` takes an `alt:`.
6//!
7//! This module adds the linter its `ROADMAP.md` has asked for since 1.5 and never shipped.
8
9use crate::core::{Element, Node};
10
11/// A landmark or widget role, for [`Element::set_role`](crate::Element::set_role).
12///
13/// Winged-Swift accepts any string. This enum catches the typos while
14/// [`Role::Custom`] keeps the door open for roles it does not know.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Role {
17    /// `role="banner"` — the page header.
18    Banner,
19    /// `role="navigation"`.
20    Navigation,
21    /// `role="main"`.
22    Main,
23    /// `role="complementary"` — a sidebar.
24    Complementary,
25    /// `role="contentinfo"` — the page footer.
26    Contentinfo,
27    /// `role="search"`.
28    Search,
29    /// `role="form"`.
30    Form,
31    /// `role="region"`.
32    Region,
33    /// `role="button"`.
34    Button,
35    /// `role="dialog"`.
36    Dialog,
37    /// `role="alert"`.
38    Alert,
39    /// `role="status"`.
40    Status,
41    /// Any other role, passed through verbatim.
42    Custom(String),
43}
44
45impl Role {
46    /// The attribute value for this role.
47    #[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/// Something in the tree that will be hard or impossible to use with assistive technology.
68#[derive(Debug, Clone, PartialEq, Eq)]
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 are the ones Winged-Swift's `ROADMAP.md` lists under "Quality":
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 winged_rust::prelude::*;
92/// use winged_rust::accessibility::audit;
93///
94/// let good = image("/a.png", "A cat");
95/// assert!(audit(&good.clone().into()).is_empty());
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
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
153/// Whether an element has a name a screen reader can announce.
154fn 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
168/// Panics in debug builds if the subtree has accessibility problems.
169///
170/// Compiled away entirely in release builds, so it costs nothing in production.
171///
172/// # Panics
173/// In debug builds, if [`audit`] reports any issue.
174pub 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}