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 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 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 && 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 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 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 Role::Heading => Some(1),
334 _ => None,
335 }
336}
337
338fn role_from_element_data(element_data: &ElementData) -> Option<Role> {
339 match &*element_data.name.local {
341 "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 "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 "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 "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 "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}