use crate::{ BaseDocument, ElementData, Node as BlitzDomNode, NodeData, NodeId as BlitzDomNodeId, local_name, }; use accesskit::{Node as AccessKitNode, NodeId, Role, TreeId, TreeInfo, TreeUpdate}; use style::properties::longhands::visibility; impl BaseDocument { pub fn build_accessibility_tree(&self) -> TreeUpdate { let mut nodes = std::collections::HashMap::new(); let mut window = AccessKitNode::new(Role::Window); let mut hidden_nodes = std::collections::HashSet::new(); let mut labelled_by_nodes = Vec::new(); let mut described_by_nodes = Vec::new(); self.visit(|node_id, node| { if node.is_hidden_from_accessibility_tree() || node .parent .map(|p| hidden_nodes.contains(&p)) .unwrap_or(false) { hidden_nodes.insert(node_id); return; } let parent = node .parent .and_then(|parent_id| nodes.get_mut(&parent_id)) .map(|(_, parent)| parent) .unwrap_or(&mut window); let (id, builder) = self.build_accessibility_node(node, parent); if node.attr(local_name!("aria-labelledby")).is_some() { labelled_by_nodes.push(node_id); } if node.attr(local_name!("aria-describedby")).is_some() { described_by_nodes.push(node_id); } nodes.insert(node_id, (id, builder)); }); let mut label = FlatText::default(); for node_id in labelled_by_nodes { let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-labelledby")) else { continue; }; let Some((id, mut builder)) = nodes.remove(&node_id) else { continue; }; let mut has_target = false; label.clear(); for target in self.referenced_dom_ids_to_node_ids(id_refs) { if !has_target { builder.clear_labelled_by(); has_target = true; } if target == node_id || nodes.contains_key(&target) { builder.push_labelled_by(NodeId(target.as_u64())); } self.nodes[target].write_text_alternative(&mut label); label.break_word(); } if !label.text.is_empty() { builder.set_label(label.text.as_str()); } nodes.insert(node_id, (id, builder)); } for node_id in described_by_nodes { let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-describedby")) else { continue; }; let Some((id, mut builder)) = nodes.remove(&node_id) else { continue; }; for target in self.referenced_dom_ids_to_node_ids(id_refs) { if target == node_id || nodes.contains_key(&target) { builder.push_described_by(NodeId(target.as_u64())); } } nodes.insert(node_id, (id, builder)); } let mut nodes: Vec<_> = nodes .into_iter() .map(|(_, (id, node))| (id, node)) .collect(); nodes.push((NodeId(u64::MAX), window)); let tree = TreeInfo::new(NodeId(u64::MAX)); TreeUpdate { tree_id: TreeId::ROOT, nodes, tree: Some(tree), focus: NodeId(self.focus_node_id.map(|id| id.as_u64()).unwrap_or(u64::MAX)), } } fn referenced_dom_ids_to_node_ids<'a>( &'a self, nodes_reference: &'a str, ) -> impl Iterator + 'a { nodes_reference .split_ascii_whitespace() .filter_map(|dom_id| self.nodes_to_id.get(dom_id)?.first().copied()) } fn build_accessibility_node( &self, node: &BlitzDomNode, parent: &mut AccessKitNode, ) -> (NodeId, AccessKitNode) { let id = NodeId(node.id.as_u64()); let mut builder = AccessKitNode::default(); if node.parent.is_none() { builder.set_role(Role::Window) } else if let Some(element_data) = node.element_data() { let name = element_data.name.local.to_string(); let role_attr = element_data.attr(local_name!("role")); // TODO: The roles of elements with strong native semantics cannot be overridden; see // https://www.w3.org/TR/wai-aria-1.2/#host_general_conflict. let role = role_attr .and_then(role_from_name) .or_else(|| role_from_element_data(element_data)) .unwrap_or(Role::Unknown); builder.set_role(role); // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion if element_data.attr(local_name!("aria-hidden")) == Some("true") { builder.set_hidden(); } if let Some(aria_expanded) = element_data.attr(local_name!("aria-expanded")) && let Ok(aria_expanded) = aria_expanded.parse() { builder.set_expanded(aria_expanded); } if element_data.attr(local_name!("aria-disabled")) == Some("true") { builder.set_disabled(); } if let Some(aria_selected) = element_data.attr(local_name!("aria-selected")) && let Ok(aria_selected) = aria_selected.parse() { builder.set_selected(aria_selected); } if let Some(aria_label) = element_data.attr(local_name!("aria-label")) { builder.set_label(aria_label); } if let Some(aria_description) = element_data.attr(local_name!("aria-description")) { builder.set_description(aria_description); } if let Some(aria_level) = element_data.attr(local_name!("aria-level")) && let Ok(aria_level) = aria_level.parse::() // Accesskit levels are 0-based, while ARIA levels are 1-based // See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level && let Some(aria_level) = aria_level.checked_sub(1) { builder.set_level(aria_level); } else if let Some(default_level) = default_level_for_tag(&name) { builder.set_level(default_level); } else if let Some(default_level) = default_level_for_role(role) { builder.set_level(default_level); } builder.set_html_tag(name); } else if node.is_text_node() { builder.set_role(Role::TextRun); builder.set_value(node.text_content()); parent.push_labelled_by(id) } parent.push_child(id); (id, builder) } } impl BlitzDomNode { // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion fn is_hidden_from_accessibility_tree(&self) -> bool { self.try_stylo_element_data() .as_ref() .and_then(|s| s.get()) .map(|s| { s.styles.is_display_none() || s.styles.primary().clone_visibility() == visibility::computed_value::T::Hidden }) .unwrap_or(false) } fn write_text_alternative(&self, out: &mut FlatText) { match self .attr(local_name!("aria-label")) .filter(|aria_label| !aria_label.trim_ascii().is_empty()) { Some(aria_label) => out.push(aria_label), None => self.write_flat_text_content(out), } } fn write_flat_text_content(&self, out: &mut FlatText) { match &self.data { NodeData::Text(data) => out.push(&data.content), NodeData::Element(..) | NodeData::AnonymousBlock(..) => { for &child_id in &self.children { self.with(child_id).write_flat_text_content(out); } } _ => {} } } } #[derive(Default)] struct FlatText { text: String, pending_space: bool, } impl FlatText { fn push(&mut self, text: &str) { for (i, word) in text.split(|c: char| c.is_ascii_whitespace()).enumerate() { self.pending_space |= i > 0; if word.is_empty() { continue; } if self.pending_space && !self.text.is_empty() { self.text.push(' '); } self.pending_space = false; self.text.push_str(word); } } fn break_word(&mut self) { self.pending_space = true; } fn clear(&mut self) { self.text.clear(); self.pending_space = false; } } fn role_from_name(name: &str) -> Option { match name { "alert" => Some(Role::Alert), "alertdialog" => Some(Role::AlertDialog), "button" => Some(Role::Button), "checkbox" => Some(Role::CheckBox), "dialog" => Some(Role::Dialog), "gridcell" => Some(Role::GridCell), "link" => Some(Role::Link), "log" => Some(Role::Log), "marquee" => Some(Role::Marquee), "menuitem" => Some(Role::MenuItem), "menuitemcheckbox" => Some(Role::MenuItemCheckBox), "menuitemradio" => Some(Role::MenuItemRadio), "option" => Some(Role::ListBoxOption), "progressbar" => Some(Role::ProgressIndicator), "radio" => Some(Role::RadioButton), "scrollbar" => Some(Role::ScrollBar), "slider" => Some(Role::Slider), "spinbutton" => Some(Role::SpinButton), "status" => Some(Role::Status), "tab" => Some(Role::Tab), "tabpanel" => Some(Role::TabPanel), "textbox" => Some(Role::TextInput), "timer" => Some(Role::Timer), "tooltip" => Some(Role::Tooltip), "treeitem" => Some(Role::TreeItem), "combobox" => Some(Role::ComboBox), "grid" => Some(Role::Grid), "listbox" => Some(Role::ListBox), "menu" => Some(Role::Menu), "menubar" => Some(Role::MenuBar), "radiogroup" => Some(Role::RadioGroup), "tablist" => Some(Role::TabList), "tree" => Some(Role::Tree), "treegrid" => Some(Role::TreeGrid), "article" => Some(Role::Article), "columnheader" => Some(Role::ColumnHeader), "definition" => Some(Role::Definition), "document" => Some(Role::Document), "group" => Some(Role::Group), "heading" => Some(Role::Heading), "img" => Some(Role::Image), "list" => Some(Role::List), "listitem" => Some(Role::ListItem), "math" => Some(Role::Math), "note" => Some(Role::Note), "region" => Some(Role::Region), "row" => Some(Role::Row), "rowgroup" => Some(Role::RowGroup), "rowheader" => Some(Role::RowHeader), "toolbar" => Some(Role::Toolbar), "application" => Some(Role::Application), "banner" => Some(Role::Banner), "complementary" => Some(Role::Complementary), "contentinfo" => Some(Role::ContentInfo), "form" => Some(Role::Form), "main" => Some(Role::Main), "navigation" => Some(Role::Navigation), "search" => Some(Role::Search), _ => None, } } fn default_level_for_tag(name: &str) -> Option { // Accesskit levels are 0-based, while ARIA levels are 1-based // See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level match name { "h1" => Some(0), "h2" => Some(1), "h3" => Some(2), "h4" => Some(3), "h5" => Some(4), "h6" => Some(5), _ => None, } } fn default_level_for_role(role: Role) -> Option { match role { // ARIA specifies an implicit level of 2 for the heading role. Accesskit levels are 0-based, // so this turns into level 1 Role::Heading => Some(1), _ => None, } } fn role_from_element_data(element_data: &ElementData) -> Option { // match &*element_data.name.local { // Document structure "article" => Some(Role::Article), "aside" => Some(Role::Complementary), "footer" => Some(Role::Footer), "header" => Some(Role::Header), "main" => Some(Role::Main), "nav" => Some(Role::Navigation), "search" => Some(Role::Search), "section" => Some(Role::Section), "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Some(Role::Heading), "p" => Some(Role::Paragraph), "blockquote" => Some(Role::Blockquote), "figure" => Some(Role::Figure), "figcaption" | "caption" => Some(Role::Caption), "hr" => Some(Role::Splitter), // Grouping "ul" | "ol" | "menu" => Some(Role::List), "li" => Some(Role::ListItem), "dl" => Some(Role::DescriptionList), "dt" => Some(Role::Term), "dd" => Some(Role::Definition), "dialog" => Some(Role::Dialog), "fieldset" => Some(Role::Group), "form" => Some(Role::Form), "div" => Some(Role::GenericContainer), // Tables "table" => Some(Role::Table), "thead" | "tbody" | "tfoot" => Some(Role::RowGroup), "tr" => Some(Role::Row), "td" => Some(Role::Cell), "th" => match element_data.attr(local_name!("scope")) { Some("row") | Some("rowgroup") => Some(Role::RowHeader), _ => Some(Role::ColumnHeader), }, // Interactive // An is only a link when it has an href. "a" => match element_data.attr(local_name!("href")) { Some(_) => Some(Role::Link), None => Some(Role::GenericContainer), }, "button" => Some(Role::Button), "label" => Some(Role::Label), "legend" => Some(Role::Label), "select" => match element_data.attr(local_name!("multiple")) { Some(_) => Some(Role::ListBox), None => Some(Role::ComboBox), }, "option" => Some(Role::ListBoxOption), "textarea" => Some(Role::MultilineTextInput), "progress" => Some(Role::ProgressIndicator), "meter" => Some(Role::Meter), "output" => Some(Role::Status), "summary" => Some(Role::DisclosureTriangle), // Inline semantics "code" => Some(Role::Code), "em" => Some(Role::Emphasis), "strong" => Some(Role::Strong), "mark" => Some(Role::Mark), "time" => Some(Role::Time), "img" => Some(Role::Image), "iframe" => Some(Role::Iframe), "input" => { let ty = element_data.attr(local_name!("type")).unwrap_or("text"); match ty { "button" | "submit" | "reset" => Some(Role::Button), "checkbox" => Some(Role::CheckBox), "color" => Some(Role::ColorWell), "date" => Some(Role::DateInput), "datetime-local" => Some(Role::DateTimeInput), "email" => Some(Role::EmailInput), "number" => Some(Role::NumberInput), "password" => Some(Role::PasswordInput), "radio" => Some(Role::RadioButton), "range" => Some(Role::Slider), "search" => Some(Role::SearchInput), "tel" => Some(Role::PhoneNumberInput), "time" => Some(Role::TimeInput), _ => Some(Role::TextInput), } } _ => None, } }