diff --git a/Cargo.lock b/Cargo.lock index 3321e20335..1fadb41229 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -907,6 +907,7 @@ dependencies = [ "parley", "percent-encoding", "rayon", + "rustc-hash 2.1.3", "selectors", "skrifa", "slotmap", @@ -1068,6 +1069,7 @@ dependencies = [ "dioxus-native-dom", "keyboard-types", "markup5ever", + "rstest", "stylo", "taffy", "test-that", @@ -6767,6 +6769,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -6892,6 +6900,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "rstest" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b376e512f30a7686a8dbc6aeaace99b3ade1c26c1c63473473a817ade226dafc" +dependencies = [ + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88512dbfb5b73a0dfa38aef7bc73b5657035fce477b923faf12d332dd83051ea" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "rusqlite" version = "0.32.1" diff --git a/Cargo.toml b/Cargo.toml index 7e73613667..7e5e782fdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -189,6 +189,7 @@ bitflags = "2.8.0" bytemuck = "1" rayon = "1" test-that = "0.6.0" +rstest = { version = "0.28", default-features = false } thread_local = "1" # Unicode (ICU4X) diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index a36fdfa3d7..fabef756db 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -85,6 +85,7 @@ writeable = { workspace = true, optional = true } # Other dependencies slotmap = { workspace = true } bitflags = { workspace = true } +rustc-hash = { workspace = true } tracing = { workspace = true, optional = true } rayon = { workspace = true } diff --git a/packages/blitz-dom/src/accessibility.rs b/packages/blitz-dom/src/accessibility.rs index 6b1b2f721f..1cc7e37a48 100644 --- a/packages/blitz-dom/src/accessibility.rs +++ b/packages/blitz-dom/src/accessibility.rs @@ -1,223 +1,58 @@ -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; -use stylo_taffy::CopyStyleGetters; +mod node; +mod tree_builder; + +use crate::{BaseDocument, Node, accessibility::tree_builder::TreeBuilder}; +use accesskit::TreeUpdate; +use blitz_traits::NodeId; +use markup5ever::local_name; impl BaseDocument { + /// Builds the AccessKit tree of this document. + /// + /// The root is a window node with the ID `u64::MAX`, whose only child is the document node. + /// The other node IDs are the raw IDs of the DOM nodes. Nodes hidden with CSS, by + /// `display: none` or `visibility: hidden`, are left out along with their descendants. Nodes + /// with `aria-hidden="true"` are included and flagged as hidden. + /// + /// The label and description of a node are its accessible name and description as computed by + /// the W3C specification, except that `title` only provides the description and never the + /// name. + /// + /// 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)), - } + TreeBuilder::new(self).build() } + /// The elements whose IDs appear in `nodes_reference`, a space-separated list, in that order. + /// IDs which match no element are skipped. fn referenced_dom_ids_to_node_ids<'a>( &'a self, nodes_reference: &'a str, - ) -> impl Iterator + 'a { + ) -> impl Iterator + 'a { nodes_reference .split_ascii_whitespace() - .filter_map(|dom_id| self.nodes_to_id.get(dom_id)?.first().copied()) + .filter_map(|dom_id| self.get_element_by_id(dom_id)) } - 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); - } - } - _ => {} + /// The element labelled by the given `