Skip to main content

apace_dom/
accessibility.rs

1use crate::{
2    BaseDocument, ElementData, Node as ApaceDomNode, NodeData, NodeId as ApaceDomNodeId, local_name,
3};
4use accesskit::{Node as AccessKitNode, NodeId, Role, TreeId, TreeInfo, TreeUpdate};
5use style::properties::longhands::visibility;
6
7impl BaseDocument {
8    pub fn build_accessibility_tree(&self) -> TreeUpdate {
9        let mut nodes = std::collections::HashMap::new();
10        let mut window = AccessKitNode::new(Role::Window);
11        let mut hidden_nodes = std::collections::HashSet::new();
12        let mut labelled_by_nodes = Vec::new();
13        let mut described_by_nodes = Vec::new();
14
15        self.visit(|node_id, node| {
16            if node.is_hidden_from_accessibility_tree()
17                || node
18                    .parent
19                    .map(|p| hidden_nodes.contains(&p))
20                    .unwrap_or(false)
21            {
22                hidden_nodes.insert(node_id);
23                return;
24            }
25            let parent = node
26                .parent
27                .and_then(|parent_id| nodes.get_mut(&parent_id))
28                .map(|(_, parent)| parent)
29                .unwrap_or(&mut window);
30            let (id, builder) = self.build_accessibility_node(node, parent);
31
32            if node.attr(local_name!("aria-labelledby")).is_some() {
33                labelled_by_nodes.push(node_id);
34            }
35            if node.attr(local_name!("aria-describedby")).is_some() {
36                described_by_nodes.push(node_id);
37            }
38            nodes.insert(node_id, (id, builder));
39        });
40
41        let mut label = FlatText::default();
42        for node_id in labelled_by_nodes {
43            let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-labelledby")) else {
44                continue;
45            };
46            let Some((id, mut builder)) = nodes.remove(&node_id) else {
47                continue;
48            };
49            let mut has_target = false;
50            label.clear();
51            for target in self.referenced_dom_ids_to_node_ids(id_refs) {
52                if !has_target {
53                    builder.clear_labelled_by();
54                    has_target = true;
55                }
56                if target == node_id || nodes.contains_key(&target) {
57                    builder.push_labelled_by(NodeId(target.as_u64()));
58                }
59                self.nodes[target].write_text_alternative(&mut label);
60                label.break_word();
61            }
62            if !label.text.is_empty() {
63                builder.set_label(label.text.as_str());
64            }
65            nodes.insert(node_id, (id, builder));
66        }
67
68        for node_id in described_by_nodes {
69            let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-describedby")) else {
70                continue;
71            };
72            let Some((id, mut builder)) = nodes.remove(&node_id) else {
73                continue;
74            };
75            for target in self.referenced_dom_ids_to_node_ids(id_refs) {
76                if target == node_id || nodes.contains_key(&target) {
77                    builder.push_described_by(NodeId(target.as_u64()));
78                }
79            }
80            nodes.insert(node_id, (id, builder));
81        }
82
83        let mut nodes: Vec<_> = nodes
84            .into_iter()
85            .map(|(_, (id, node))| (id, node))
86            .collect();
87        nodes.push((NodeId(u64::MAX), window));
88
89        let tree = TreeInfo::new(NodeId(u64::MAX));
90        TreeUpdate {
91            tree_id: TreeId::ROOT,
92            nodes,
93            tree: Some(tree),
94            focus: NodeId(self.focus_node_id.map(|id| id.as_u64()).unwrap_or(u64::MAX)),
95        }
96    }
97
98    fn referenced_dom_ids_to_node_ids<'a>(
99        &'a self,
100        nodes_reference: &'a str,
101    ) -> impl Iterator<Item = ApaceDomNodeId> + 'a {
102        nodes_reference
103            .split_ascii_whitespace()
104            .filter_map(|dom_id| self.nodes_to_id.get(dom_id)?.first().copied())
105    }
106
107    fn build_accessibility_node(
108        &self,
109        node: &ApaceDomNode,
110        parent: &mut AccessKitNode,
111    ) -> (NodeId, AccessKitNode) {
112        let id = NodeId(node.id.as_u64());
113
114        let mut builder = AccessKitNode::default();
115        if node.parent.is_none() {
116            builder.set_role(Role::Window)
117        } else if let Some(element_data) = node.element_data() {
118            let name = element_data.name.local.to_string();
119            let role_attr = element_data.attr(local_name!("role"));
120
121            // TODO: The roles of elements with strong native semantics cannot be overridden; see
122            // https://www.w3.org/TR/wai-aria-1.2/#host_general_conflict.
123            let role = role_attr
124                .and_then(role_from_name)
125                .or_else(|| role_from_element_data(element_data))
126                .unwrap_or(Role::Unknown);
127
128            builder.set_role(role);
129
130            // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
131            if element_data.attr(local_name!("aria-hidden")) == Some("true") {
132                builder.set_hidden();
133            }
134
135            if let Some(aria_expanded) = element_data.attr(local_name!("aria-expanded"))
136                && let Ok(aria_expanded) = aria_expanded.parse()
137            {
138                builder.set_expanded(aria_expanded);
139            }
140
141            if element_data.attr(local_name!("aria-disabled")) == Some("true") {
142                builder.set_disabled();
143            }
144
145            if let Some(aria_selected) = element_data.attr(local_name!("aria-selected"))
146                && let Ok(aria_selected) = aria_selected.parse()
147            {
148                builder.set_selected(aria_selected);
149            }
150
151            if let Some(aria_label) = element_data.attr(local_name!("aria-label")) {
152                builder.set_label(aria_label);
153            }
154            if let Some(aria_description) = element_data.attr(local_name!("aria-description")) {
155                builder.set_description(aria_description);
156            }
157            if let Some(aria_level) = element_data.attr(local_name!("aria-level"))
158                && let Ok(aria_level) = aria_level.parse::<usize>()
159                // Accesskit levels are 0-based, while ARIA levels are 1-based
160                // See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level
161                && let Some(aria_level) = aria_level.checked_sub(1)
162            {
163                builder.set_level(aria_level);
164            } else if let Some(default_level) = default_level_for_tag(&name) {
165                builder.set_level(default_level);
166            } else if let Some(default_level) = default_level_for_role(role) {
167                builder.set_level(default_level);
168            }
169
170            builder.set_html_tag(name);
171        } else if node.is_text_node() {
172            builder.set_role(Role::TextRun);
173            builder.set_value(node.text_content());
174            parent.push_labelled_by(id)
175        }
176
177        parent.push_child(id);
178
179        (id, builder)
180    }
181}
182
183impl ApaceDomNode {
184    // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
185    fn is_hidden_from_accessibility_tree(&self) -> bool {
186        self.try_stylo_element_data()
187            .as_ref()
188            .and_then(|s| s.get())
189            .map(|s| {
190                s.styles.is_display_none()
191                    || s.styles.primary().clone_visibility()
192                        == visibility::computed_value::T::Hidden
193            })
194            .unwrap_or(false)
195    }
196
197    fn write_text_alternative(&self, out: &mut FlatText) {
198        match self
199            .attr(local_name!("aria-label"))
200            .filter(|aria_label| !aria_label.trim_ascii().is_empty())
201        {
202            Some(aria_label) => out.push(aria_label),
203            None => self.write_flat_text_content(out),
204        }
205    }
206
207    fn write_flat_text_content(&self, out: &mut FlatText) {
208        match &self.data {
209            NodeData::Text(data) => out.push(&data.content),
210            NodeData::Element(..) | NodeData::AnonymousBlock(..) => {
211                for &child_id in &self.children {
212                    self.with(child_id).write_flat_text_content(out);
213                }
214            }
215            _ => {}
216        }
217    }
218}
219
220#[derive(Default)]
221struct FlatText {
222    text: String,
223    pending_space: bool,
224}
225
226impl FlatText {
227    fn push(&mut self, text: &str) {
228        for (i, word) in text.split(|c: char| c.is_ascii_whitespace()).enumerate() {
229            self.pending_space |= i > 0;
230            if word.is_empty() {
231                continue;
232            }
233            if self.pending_space && !self.text.is_empty() {
234                self.text.push(' ');
235            }
236            self.pending_space = false;
237            self.text.push_str(word);
238        }
239    }
240
241    fn break_word(&mut self) {
242        self.pending_space = true;
243    }
244
245    fn clear(&mut self) {
246        self.text.clear();
247        self.pending_space = false;
248    }
249}
250
251fn role_from_name(name: &str) -> Option<Role> {
252    match name {
253        "alert" => Some(Role::Alert),
254        "alertdialog" => Some(Role::AlertDialog),
255        "button" => Some(Role::Button),
256        "checkbox" => Some(Role::CheckBox),
257        "dialog" => Some(Role::Dialog),
258        "gridcell" => Some(Role::GridCell),
259        "link" => Some(Role::Link),
260        "log" => Some(Role::Log),
261        "marquee" => Some(Role::Marquee),
262        "menuitem" => Some(Role::MenuItem),
263        "menuitemcheckbox" => Some(Role::MenuItemCheckBox),
264        "menuitemradio" => Some(Role::MenuItemRadio),
265        "option" => Some(Role::ListBoxOption),
266        "progressbar" => Some(Role::ProgressIndicator),
267        "radio" => Some(Role::RadioButton),
268        "scrollbar" => Some(Role::ScrollBar),
269        "slider" => Some(Role::Slider),
270        "spinbutton" => Some(Role::SpinButton),
271        "status" => Some(Role::Status),
272        "tab" => Some(Role::Tab),
273        "tabpanel" => Some(Role::TabPanel),
274        "textbox" => Some(Role::TextInput),
275        "timer" => Some(Role::Timer),
276        "tooltip" => Some(Role::Tooltip),
277        "treeitem" => Some(Role::TreeItem),
278        "combobox" => Some(Role::ComboBox),
279        "grid" => Some(Role::Grid),
280        "listbox" => Some(Role::ListBox),
281        "menu" => Some(Role::Menu),
282        "menubar" => Some(Role::MenuBar),
283        "radiogroup" => Some(Role::RadioGroup),
284        "tablist" => Some(Role::TabList),
285        "tree" => Some(Role::Tree),
286        "treegrid" => Some(Role::TreeGrid),
287        "article" => Some(Role::Article),
288        "columnheader" => Some(Role::ColumnHeader),
289        "definition" => Some(Role::Definition),
290        "document" => Some(Role::Document),
291        "group" => Some(Role::Group),
292        "heading" => Some(Role::Heading),
293        "img" => Some(Role::Image),
294        "list" => Some(Role::List),
295        "listitem" => Some(Role::ListItem),
296        "math" => Some(Role::Math),
297        "note" => Some(Role::Note),
298        "region" => Some(Role::Region),
299        "row" => Some(Role::Row),
300        "rowgroup" => Some(Role::RowGroup),
301        "rowheader" => Some(Role::RowHeader),
302        "toolbar" => Some(Role::Toolbar),
303        "application" => Some(Role::Application),
304        "banner" => Some(Role::Banner),
305        "complementary" => Some(Role::Complementary),
306        "contentinfo" => Some(Role::ContentInfo),
307        "form" => Some(Role::Form),
308        "main" => Some(Role::Main),
309        "navigation" => Some(Role::Navigation),
310        "search" => Some(Role::Search),
311        _ => None,
312    }
313}
314
315fn default_level_for_tag(name: &str) -> Option<usize> {
316    // Accesskit levels are 0-based, while ARIA levels are 1-based
317    // See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level
318    match name {
319        "h1" => Some(0),
320        "h2" => Some(1),
321        "h3" => Some(2),
322        "h4" => Some(3),
323        "h5" => Some(4),
324        "h6" => Some(5),
325        _ => None,
326    }
327}
328
329fn default_level_for_role(role: Role) -> Option<usize> {
330    match role {
331        // ARIA specifies an implicit level of 2 for the heading role. Accesskit levels are 0-based,
332        // so this turns into level 1
333        Role::Heading => Some(1),
334        _ => None,
335    }
336}
337
338fn role_from_element_data(element_data: &ElementData) -> Option<Role> {
339    // <https://www.w3.org/TR/html-aam-1.0/>
340    match &*element_data.name.local {
341        // Document structure
342        "article" => Some(Role::Article),
343        "aside" => Some(Role::Complementary),
344        "footer" => Some(Role::Footer),
345        "header" => Some(Role::Header),
346        "main" => Some(Role::Main),
347        "nav" => Some(Role::Navigation),
348        "search" => Some(Role::Search),
349        "section" => Some(Role::Section),
350        "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Some(Role::Heading),
351        "p" => Some(Role::Paragraph),
352        "blockquote" => Some(Role::Blockquote),
353        "figure" => Some(Role::Figure),
354        "figcaption" | "caption" => Some(Role::Caption),
355        "hr" => Some(Role::Splitter),
356
357        // Grouping
358        "ul" | "ol" | "menu" => Some(Role::List),
359        "li" => Some(Role::ListItem),
360        "dl" => Some(Role::DescriptionList),
361        "dt" => Some(Role::Term),
362        "dd" => Some(Role::Definition),
363        "dialog" => Some(Role::Dialog),
364        "fieldset" => Some(Role::Group),
365        "form" => Some(Role::Form),
366        "div" => Some(Role::GenericContainer),
367
368        // Tables
369        "table" => Some(Role::Table),
370        "thead" | "tbody" | "tfoot" => Some(Role::RowGroup),
371        "tr" => Some(Role::Row),
372        "td" => Some(Role::Cell),
373        "th" => match element_data.attr(local_name!("scope")) {
374            Some("row") | Some("rowgroup") => Some(Role::RowHeader),
375            _ => Some(Role::ColumnHeader),
376        },
377
378        // Interactive
379        // An <a> is only a link when it has an href.
380        "a" => match element_data.attr(local_name!("href")) {
381            Some(_) => Some(Role::Link),
382            None => Some(Role::GenericContainer),
383        },
384        "button" => Some(Role::Button),
385        "label" => Some(Role::Label),
386        "legend" => Some(Role::Label),
387        "select" => match element_data.attr(local_name!("multiple")) {
388            Some(_) => Some(Role::ListBox),
389            None => Some(Role::ComboBox),
390        },
391        "option" => Some(Role::ListBoxOption),
392        "textarea" => Some(Role::MultilineTextInput),
393        "progress" => Some(Role::ProgressIndicator),
394        "meter" => Some(Role::Meter),
395        "output" => Some(Role::Status),
396        "summary" => Some(Role::DisclosureTriangle),
397
398        // Inline semantics
399        "code" => Some(Role::Code),
400        "em" => Some(Role::Emphasis),
401        "strong" => Some(Role::Strong),
402        "mark" => Some(Role::Mark),
403        "time" => Some(Role::Time),
404        "img" => Some(Role::Image),
405        "iframe" => Some(Role::Iframe),
406
407        "input" => {
408            let ty = element_data.attr(local_name!("type")).unwrap_or("text");
409            match ty {
410                "button" | "submit" | "reset" => Some(Role::Button),
411                "checkbox" => Some(Role::CheckBox),
412                "color" => Some(Role::ColorWell),
413                "date" => Some(Role::DateInput),
414                "datetime-local" => Some(Role::DateTimeInput),
415                "email" => Some(Role::EmailInput),
416                "number" => Some(Role::NumberInput),
417                "password" => Some(Role::PasswordInput),
418                "radio" => Some(Role::RadioButton),
419                "range" => Some(Role::Slider),
420                "search" => Some(Role::SearchInput),
421                "tel" => Some(Role::PhoneNumberInput),
422                "time" => Some(Role::TimeInput),
423                _ => Some(Role::TextInput),
424            }
425        }
426        _ => None,
427    }
428}