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.link_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_flattened(|node_id, parent_id, node| {
171            let parent = parent_id
172                .and_then(|parent_id| nodes.get_mut(&parent_id))
173                .map(|(_, parent)| parent)
174                .unwrap_or(&mut window);
175            let (id, builder) = self.build_accessibility_node(node, parent);
176
177            nodes.insert(node_id, (id, builder));
178        });
179
180        let mut nodes: Vec<_> = nodes
181            .into_iter()
182            .map(|(_, (id, node))| (id, node))
183            .collect();
184        nodes.push((NodeId(u64::MAX), window));
185
186        let tree = Tree::new(NodeId(u64::MAX));
187        TreeUpdate {
188            tree_id: TreeId::ROOT,
189            nodes,
190            tree: Some(tree),
191            focus: NodeId(self.focus_node_id.map(|id| id.as_u64()).unwrap_or(u64::MAX)),
192        }
193    }
194
195    fn build_accessibility_node(
196        &self,
197        node: &BlitzDomNode,
198        parent: &mut AccessKitNode,
199    ) -> (NodeId, AccessKitNode) {
200        let id = NodeId(node.id.as_u64());
201
202        let mut builder = AccessKitNode::default();
203        if node.parent.is_none() {
204            builder.set_role(Role::Window)
205        } else if let Some(element_data) = node.element_data() {
206            builder.set_role(implicit_role(element_data));
207            // Roles alone do not expose a picker's current value or its options.
208            // Read live selectedness so keyboard and script changes reach the tree.
209            match element_data.name.local.as_ref() {
210                "select" => builder.set_value(self.select_label(node.id)),
211                "option" => {
212                    builder.set_label(self.option_label(node.id));
213                    builder.set_selected(self.option_is_selected(node.id));
214                }
215                _ => {}
216            }
217            builder.set_html_tag(element_data.name.local.to_string());
218        } else if node.is_text_node() {
219            builder.set_role(Role::TextRun);
220            builder.set_value(node.text_content());
221            parent.push_labelled_by(id)
222        }
223
224        parent.push_child(id);
225
226        (id, builder)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use markup5ever::{QualName, ns};
233
234    use crate::node::Attribute;
235
236    use super::*;
237
238    /// One element, built directly.
239    ///
240    /// `blitz-dom` holds the tree but does not parse HTML, so a fixture here
241    /// cannot be a string of markup. The role rules read a tag name and a
242    /// handful of attributes and nothing else, which is exactly what this
243    /// supplies.
244    fn element(tag: &str, attributes: &[(&str, &str)]) -> ElementData {
245        ElementData::new(
246            QualName::new(None, ns!(html), tag.into()),
247            attributes
248                .iter()
249                .map(|(name, value)| Attribute {
250                    name: QualName::new(None, ns!(), (*name).into()),
251                    value: (*value).into(),
252                })
253                .collect(),
254        )
255    }
256
257    fn role_of(tag: &str, attributes: &[(&str, &str)]) -> Role {
258        implicit_role(&element(tag, attributes))
259    }
260
261    /// The case that showed there were two tables.
262    ///
263    /// A header cell reported as a plain cell is not a cosmetic difference: it
264    /// is the difference between a document that says which column a value
265    /// belongs to and one that does not.
266    /// The case that showed there were two tables.
267    ///
268    /// A header cell reported as a plain cell is not a cosmetic difference: it
269    /// is the difference between a document that says which column a value
270    /// belongs to and one that does not.
271    #[test]
272    fn a_header_cell_is_a_header_and_scope_says_which_kind() {
273        assert_eq!(role_of("th", &[]), Role::ColumnHeader);
274        assert_eq!(role_of("th", &[("scope", "col")]), Role::ColumnHeader);
275        assert_eq!(role_of("th", &[("scope", "row")]), Role::RowHeader);
276        assert_eq!(role_of("th", &[("scope", "rowgroup")]), Role::RowHeader);
277        assert_eq!(role_of("td", &[]), Role::Cell);
278    }
279
280    #[test]
281    fn a_named_section_is_a_landmark_and_an_unnamed_one_is_not() {
282        assert_eq!(
283            role_of("section", &[("aria-label", "connection settings")]),
284            Role::Region
285        );
286        assert_eq!(
287            role_of("section", &[("aria-labelledby", "heading")]),
288            Role::Region
289        );
290        assert_eq!(role_of("section", &[("title", "notes")]), Role::Region);
291        assert_eq!(role_of("section", &[]), Role::Section);
292        assert_eq!(
293            role_of("section", &[("aria-label", "  ")]),
294            Role::Section,
295            "whitespace is not a name"
296        );
297    }
298
299    #[test]
300    fn an_anchor_is_a_link_only_when_it_goes_somewhere() {
301        assert_eq!(role_of("a", &[("href", "/x")]), Role::Link);
302        assert_eq!(role_of("a", &[]), Role::GenericContainer);
303    }
304
305    #[test]
306    fn an_input_takes_its_role_from_its_type() {
307        assert_eq!(role_of("input", &[]), Role::TextInput);
308        assert_eq!(role_of("input", &[("type", "checkbox")]), Role::CheckBox);
309        assert_eq!(role_of("input", &[("type", "radio")]), Role::RadioButton);
310        assert_eq!(role_of("input", &[("type", "range")]), Role::Slider);
311        assert_eq!(role_of("input", &[("type", "submit")]), Role::Button);
312        assert_eq!(
313            role_of("input", &[("type", "password")]),
314            Role::PasswordInput
315        );
316        assert_eq!(
317            role_of("input", &[("type", "not-a-type")]),
318            Role::TextInput,
319            "an unknown input type still edits text"
320        );
321    }
322
323    #[test]
324    fn a_multiple_select_is_a_list_box() {
325        assert_eq!(role_of("select", &[]), Role::ComboBox);
326        assert_eq!(role_of("select", &[("multiple", "")]), Role::ListBox);
327    }
328}