Skip to main content

blitz_dom/
accessibility.rs

1//! The role an element has, and the AccessKit tree built out of those roles.
2
3use crate::node::ElementData;
4use crate::{BaseDocument, Node as BlitzDomNode, local_name};
5use accesskit::{Node as AccessKitNode, NodeId, Role, Tree, TreeId, TreeUpdate};
6
7/// An attribute by local name, for the names that are not static atoms.
8///
9/// `ElementData::attr` takes something comparable to a `LocalName`, which the
10/// `local_name!` macro supplies for the names html5ever interns. `aria-label`
11/// and `aria-labelledby` are not among them, so they are matched by string.
12fn attr<'a>(element: &'a ElementData, name: &str) -> Option<&'a str> {
13    element
14        .attrs()
15        .iter()
16        .find(|attribute| attribute.name.local.as_ref() == name)
17        .map(|attribute| attribute.value.as_ref())
18}
19
20/// The role an element has by virtue of being that element, per
21/// <https://www.w3.org/TR/html-aam-1.0/>.
22///
23/// # Why this is public
24///
25/// There were two of these. This one built the AccessKit tree a screen reader
26/// reads; a second copy in `izumo`'s `agent.rs` built the
27/// semantic tree an agent and the QA harness read, and the two answered
28/// differently about the same document. `<th>` was the case that showed it:
29/// correct here as a column or row header, reported as a plain `cell` there,
30/// so a check that wanted "the Version column" had nothing to ask for while
31/// every header was spelled the same as the data beneath it.
32///
33/// Neither copy was wrong on purpose. They were written months apart against
34/// the same specification, and nothing could compare them: this table was
35/// private, and the other lived in a different repository. Exporting it is what
36/// makes a single answer possible, and the control surface in
37/// `blitz-control-protocol` now derives its own role names from this function
38/// rather than from a table of its own.
39///
40/// # What it does not do
41///
42/// An author's explicit `role` attribute is not consulted. That is an override
43/// applied on top of the implicit role, and the two consumers apply it
44/// differently: the control surface passes the author's string through
45/// verbatim, while this tree would need a full ARIA-name-to-[`Role`] table it
46/// does not have. Naming it here would decide that question for both.
47pub fn implicit_role(element: &ElementData) -> Role {
48    let name = element.name.local.as_ref();
49    match name {
50        // Document structure
51        "article" => Role::Article,
52        "aside" => Role::Complementary,
53        "footer" => Role::Footer,
54        "header" => Role::Header,
55        "main" => Role::Main,
56        "nav" => Role::Navigation,
57        "search" => Role::Search,
58        // A named section is a landmark; an unnamed one is a wrapper.
59        //
60        // HTML-AAM maps `<section>` to `region` when it has an accessible name
61        // and leaves it generic otherwise. Both halves matter. A named section
62        // is how a page says "this part is the connection settings", and
63        // without the rule it arrives indistinguishable from the `<div>`s
64        // around it; promoting the unnamed ones would put a landmark around
65        // every block on a page that reaches for `<section>` as a synonym for
66        // `<div>`.
67        //
68        // Attributes only. The accessible name is not computed here, and
69        // computing it would walk the section's whole subtree for every element
70        // in the document. `aria-labelledby` is included so an author who names
71        // a section that way still gets the landmark; resolving the reference
72        // to the name itself is the caller's job.
73        "section" => {
74            if ["aria-label", "aria-labelledby", "title"]
75                .iter()
76                .any(|name| attr(element, name).is_some_and(|value| !value.trim().is_empty()))
77            {
78                Role::Region
79            } else {
80                Role::Section
81            }
82        }
83        "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Role::Heading,
84        "p" => Role::Paragraph,
85        "blockquote" => Role::Blockquote,
86        "figure" => Role::Figure,
87        "figcaption" | "caption" => Role::Caption,
88        "hr" => Role::Splitter,
89
90        // Grouping
91        "ul" | "ol" | "menu" => Role::List,
92        "li" => Role::ListItem,
93        "dl" => Role::DescriptionList,
94        "dt" => Role::Term,
95        "dd" => Role::Definition,
96        "dialog" => Role::Dialog,
97        "fieldset" => Role::Group,
98        "form" => Role::Form,
99        "div" => Role::GenericContainer,
100
101        // Tables
102        "table" => Role::Table,
103        "thead" | "tbody" | "tfoot" => Role::RowGroup,
104        "tr" => Role::Row,
105        "td" => Role::Cell,
106        // A header cell is not a cell. `scope` decides which kind; without one
107        // this is a column header, which is the `<thead>` row case.
108        "th" => match element.attr(local_name!("scope")) {
109            Some("row") | Some("rowgroup") => Role::RowHeader,
110            _ => Role::ColumnHeader,
111        },
112
113        // Interactive
114        // An <a> is only a link when it has an href.
115        "a" => match element.attr(local_name!("href")) {
116            Some(_) => Role::Link,
117            None => Role::GenericContainer,
118        },
119        "button" => Role::Button,
120        "label" => Role::Label,
121        "legend" => Role::Label,
122        "select" => match element.attr(local_name!("multiple")) {
123            Some(_) => Role::ListBox,
124            None => Role::ComboBox,
125        },
126        "option" => Role::ListBoxOption,
127        "textarea" => Role::MultilineTextInput,
128        "progress" => Role::ProgressIndicator,
129        "meter" => Role::Meter,
130        "output" => Role::Status,
131        "summary" => Role::DisclosureTriangle,
132
133        // Inline semantics
134        "code" => Role::Code,
135        "em" => Role::Emphasis,
136        "strong" => Role::Strong,
137        "mark" => Role::Mark,
138        "time" => Role::Time,
139        "img" => Role::Image,
140        "iframe" => Role::Iframe,
141
142        "input" => {
143            let ty = element.attr(local_name!("type")).unwrap_or("text");
144            match ty {
145                "button" | "submit" | "reset" => Role::Button,
146                "checkbox" => Role::CheckBox,
147                "color" => Role::ColorWell,
148                "date" => Role::DateInput,
149                "datetime-local" => Role::DateTimeInput,
150                "email" => Role::EmailInput,
151                "number" => Role::NumberInput,
152                "password" => Role::PasswordInput,
153                "radio" => Role::RadioButton,
154                "range" => Role::Slider,
155                "search" => Role::SearchInput,
156                "tel" => Role::PhoneNumberInput,
157                "time" => Role::TimeInput,
158                _ => Role::TextInput,
159            }
160        }
161        _ => Role::Unknown,
162    }
163}
164
165impl BaseDocument {
166    pub fn build_accessibility_tree(&self) -> TreeUpdate {
167        let mut nodes = std::collections::HashMap::new();
168        let mut window = AccessKitNode::new(Role::Window);
169
170        self.visit(|node_id, node| {
171            let parent = node
172                .parent
173                .and_then(|parent_id| nodes.get_mut(&parent_id))
174                .map(|(_, parent)| parent)
175                .unwrap_or(&mut window);
176            let (id, builder) = self.build_accessibility_node(node, parent);
177
178            nodes.insert(node_id, (id, builder));
179        });
180
181        let mut nodes: Vec<_> = nodes
182            .into_iter()
183            .map(|(_, (id, node))| (id, node))
184            .collect();
185        nodes.push((NodeId(u64::MAX), window));
186
187        let tree = Tree::new(NodeId(u64::MAX));
188        TreeUpdate {
189            tree_id: TreeId::ROOT,
190            nodes,
191            tree: Some(tree),
192            focus: NodeId(self.focus_node_id.map(|id| id.as_u64()).unwrap_or(u64::MAX)),
193        }
194    }
195
196    fn build_accessibility_node(
197        &self,
198        node: &BlitzDomNode,
199        parent: &mut AccessKitNode,
200    ) -> (NodeId, AccessKitNode) {
201        let id = NodeId(node.id.as_u64());
202
203        let mut builder = AccessKitNode::default();
204        if node.parent.is_none() {
205            builder.set_role(Role::Window)
206        } else if let Some(element_data) = node.element_data() {
207            builder.set_role(implicit_role(element_data));
208            // Roles alone do not expose a picker's current value or its options.
209            // Read live selectedness so keyboard and script changes reach the tree.
210            match element_data.name.local.as_ref() {
211                "select" => builder.set_value(self.select_label(node.id)),
212                "option" => {
213                    builder.set_label(self.option_label(node.id));
214                    builder.set_selected(self.option_is_selected(node.id));
215                }
216                _ => {}
217            }
218            builder.set_html_tag(element_data.name.local.to_string());
219        } else if node.is_text_node() {
220            builder.set_role(Role::TextRun);
221            builder.set_value(node.text_content());
222            parent.push_labelled_by(id)
223        }
224
225        parent.push_child(id);
226
227        (id, builder)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use markup5ever::{QualName, ns};
234
235    use crate::node::Attribute;
236
237    use super::*;
238
239    /// One element, built directly.
240    ///
241    /// `blitz-dom` holds the tree but does not parse HTML, so a fixture here
242    /// cannot be a string of markup. The role rules read a tag name and a
243    /// handful of attributes and nothing else, which is exactly what this
244    /// supplies.
245    fn element(tag: &str, attributes: &[(&str, &str)]) -> ElementData {
246        ElementData::new(
247            QualName::new(None, ns!(html), tag.into()),
248            attributes
249                .iter()
250                .map(|(name, value)| Attribute {
251                    name: QualName::new(None, ns!(), (*name).into()),
252                    value: (*value).into(),
253                })
254                .collect(),
255        )
256    }
257
258    fn role_of(tag: &str, attributes: &[(&str, &str)]) -> Role {
259        implicit_role(&element(tag, attributes))
260    }
261
262    /// The case that showed there were two tables.
263    ///
264    /// A header cell reported as a plain cell is not a cosmetic difference: it
265    /// is the difference between a document that says which column a value
266    /// belongs to and one that does not.
267    /// The case that showed there were two tables.
268    ///
269    /// A header cell reported as a plain cell is not a cosmetic difference: it
270    /// is the difference between a document that says which column a value
271    /// belongs to and one that does not.
272    #[test]
273    fn a_header_cell_is_a_header_and_scope_says_which_kind() {
274        assert_eq!(role_of("th", &[]), Role::ColumnHeader);
275        assert_eq!(role_of("th", &[("scope", "col")]), Role::ColumnHeader);
276        assert_eq!(role_of("th", &[("scope", "row")]), Role::RowHeader);
277        assert_eq!(role_of("th", &[("scope", "rowgroup")]), Role::RowHeader);
278        assert_eq!(role_of("td", &[]), Role::Cell);
279    }
280
281    #[test]
282    fn a_named_section_is_a_landmark_and_an_unnamed_one_is_not() {
283        assert_eq!(
284            role_of("section", &[("aria-label", "connection settings")]),
285            Role::Region
286        );
287        assert_eq!(
288            role_of("section", &[("aria-labelledby", "heading")]),
289            Role::Region
290        );
291        assert_eq!(role_of("section", &[("title", "notes")]), Role::Region);
292        assert_eq!(role_of("section", &[]), Role::Section);
293        assert_eq!(
294            role_of("section", &[("aria-label", "  ")]),
295            Role::Section,
296            "whitespace is not a name"
297        );
298    }
299
300    #[test]
301    fn an_anchor_is_a_link_only_when_it_goes_somewhere() {
302        assert_eq!(role_of("a", &[("href", "/x")]), Role::Link);
303        assert_eq!(role_of("a", &[]), Role::GenericContainer);
304    }
305
306    #[test]
307    fn an_input_takes_its_role_from_its_type() {
308        assert_eq!(role_of("input", &[]), Role::TextInput);
309        assert_eq!(role_of("input", &[("type", "checkbox")]), Role::CheckBox);
310        assert_eq!(role_of("input", &[("type", "radio")]), Role::RadioButton);
311        assert_eq!(role_of("input", &[("type", "range")]), Role::Slider);
312        assert_eq!(role_of("input", &[("type", "submit")]), Role::Button);
313        assert_eq!(
314            role_of("input", &[("type", "password")]),
315            Role::PasswordInput
316        );
317        assert_eq!(
318            role_of("input", &[("type", "not-a-type")]),
319            Role::TextInput,
320            "an unknown input type still edits text"
321        );
322    }
323
324    #[test]
325    fn a_multiple_select_is_a_list_box() {
326        assert_eq!(role_of("select", &[]), Role::ComboBox);
327        assert_eq!(role_of("select", &[("multiple", "")]), Role::ListBox);
328    }
329}