From 8ced3d1b8eceead83690f2634f38e17c37c6d143 Mon Sep 17 00:00:00 2001 From: Alice <95208+alice@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:25:20 +0200 Subject: [PATCH] layout: Expose some non-interactive accessibility roles based on DOM node type. (#44255) Sets the accessibility role of nodes based on their DOM node type (ignoring ARIA roles and computed styles for now), for a small subset of roles. This change also re-works some details of accessibility tree building: - Set the `root` property for the accesskit tree to the node ID for the root DOM node, rather than creating a placeholder root node which contains the root DOM node. - Since we now map the `` node to the `RootWebArea` role, this required a small change to the accessibility test logic to do a tree walk to find the `RootWebArea` node, which used to be the first child since we artificially set the placeholder root node to be the `RootWebArea` node. - Have `update_node_and_children()` return a bool indicating whether any node was updated. - Split the code updating the node itself into its own function, `update_node()`, which takes a DOM node and returns a tuple containing the accessibility node ID and a bool indicating whether the node required updating - Add an `assert_node_by_id()` method to retrieve an accessibility node based on its ID. Testing: See new tests added in this PR. --------- Signed-off-by: Alice Boxhall Co-authored-by: delan azabani --- components/layout/accessibility_tree.rs | 117 ++++++++++++++++-------- components/servo/tests/accessibility.rs | 66 +++++++++++-- 2 files changed, 136 insertions(+), 47 deletions(-) diff --git a/components/layout/accessibility_tree.rs b/components/layout/accessibility_tree.rs index 40b0834d1c0..5290fe74a49 100644 --- a/components/layout/accessibility_tree.rs +++ b/components/layout/accessibility_tree.rs @@ -1,14 +1,16 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +use std::sync::LazyLock; use accesskit::Role; -use layout_api::LayoutNode; +use layout_api::{LayoutElement, LayoutNode, LayoutNodeType}; use log::trace; use rustc_hash::FxHashMap; use script::layout_dom::ServoLayoutNode; use servo_base::Epoch; -use style::dom::{NodeInfo, OpaqueNode}; +use style::dom::OpaqueNode; +use web_atoms::{LocalName, local_name}; use crate::ArcRefCell; @@ -35,47 +37,28 @@ impl AccessibilityTree { const ROOT_NODE_ID: accesskit::NodeId = accesskit::NodeId(0); pub(super) fn new(tree_id: accesskit::TreeId, epoch: Epoch) -> Self { - // The root node doesn't correspond to a DOM node, but contains the root DOM node. - let mut root_node = AccessibilityNode::new(AccessibilityTree::ROOT_NODE_ID); - root_node - .accesskit_node - .set_role(accesskit::Role::RootWebArea); - root_node - .accesskit_node - .add_action(accesskit::Action::Focus); - - let mut tree = Self { - nodes: Default::default(), - accesskit_tree: accesskit::Tree::new(root_node.id), + Self { + nodes: FxHashMap::default(), + accesskit_tree: accesskit::Tree::new(AccessibilityTree::ROOT_NODE_ID), tree_id, epoch, - }; - tree.nodes.insert(root_node.id, ArcRefCell::new(root_node)); - - tree + } } pub(super) fn update_tree( &mut self, root_dom_node: &ServoLayoutNode<'_>, ) -> Option { + let root_node = self.get_or_create_node(root_dom_node); + self.accesskit_tree.root = root_node.borrow().id; + let mut tree_update = AccessibilityUpdate::new(self.accesskit_tree.clone(), self.tree_id); + let any_node_updated = self.update_node_and_children(root_dom_node, &mut tree_update); - { - let root_dom_node_id = Self::to_accesskit_id(&root_dom_node.opaque()); - let root_node = self - .nodes - .get_mut(&AccessibilityTree::ROOT_NODE_ID) - .expect("Guaranteed by Self::new"); - let mut root_node = root_node.borrow_mut(); - root_node - .accesskit_node - .set_children(vec![root_dom_node_id]); - - tree_update.add(&root_node); + if !any_node_updated { + return None; } - self.update_node_and_children(root_dom_node, &mut tree_update); Some(tree_update.finalize()) } @@ -83,9 +66,26 @@ impl AccessibilityTree { &mut self, dom_node: &ServoLayoutNode<'_>, tree_update: &mut AccessibilityUpdate, - ) { - // TODO: read accessibility damage from dom_node (right now, assume damage is complete) + ) -> bool { + // TODO: read accessibility damage (right now, assume damage is complete) + let (node_id, updated) = self.update_node(dom_node); + let mut any_descendant_updated = false; + for dom_child in dom_node.flat_tree_children() { + // TODO: We actually need to propagate damage within the accessibility tree, rather than + // assuming it matches the DOM tree, but this will do for now. + any_descendant_updated |= self.update_node_and_children(&dom_child, tree_update); + } + + if updated { + let node = self.assert_node_by_id(node_id); + tree_update.add(&node.borrow()); + } + + updated || any_descendant_updated + } + + fn update_node(&mut self, dom_node: &ServoLayoutNode<'_>) -> (accesskit::NodeId, bool) { let node = self.get_or_create_node(dom_node); let mut node = node.borrow_mut(); let accesskit_node = &mut node.accesskit_node; @@ -99,21 +99,26 @@ impl AccessibilityTree { accesskit_node.set_children(new_children); } - if dom_node.is_text_node() { + if let Some(dom_element) = dom_node.as_element() { + accesskit_node.set_role(Role::GenericContainer); + let local_name = dom_element.local_name().to_ascii_lowercase(); + accesskit_node.set_html_tag(&*local_name); + let role = HTML_ELEMENT_ROLE_MAPPINGS + .get(&local_name) + .unwrap_or(&Role::GenericContainer); + accesskit_node.set_role(*role); + } else if dom_node.type_id() == Some(LayoutNodeType::Text) { accesskit_node.set_role(Role::TextRun); let text_content = dom_node.text_content(); trace!("node text content = {text_content:?}"); // FIXME: this should take into account editing selection units (grapheme clusters?) accesskit_node.set_value(&*text_content); - } else if dom_node.as_element().is_some() { + } else { accesskit_node.set_role(Role::GenericContainer); } - tree_update.add(&node); - - for dom_child in dom_node.flat_tree_children() { - self.update_node_and_children(&dom_child, tree_update); - } + // TODO: only return true if any update actually happened + (node.id, true) } fn get_or_create_node( @@ -129,6 +134,16 @@ impl AccessibilityTree { node.clone() } + fn assert_node_by_id(&self, id: accesskit::NodeId) -> ArcRefCell { + let Some(node) = self.nodes.get(&id) else { + panic!("Stale node ID found: {id:?}"); + }; + node.clone() + } + + // TODO: Using the OpaqueNode as the identifier for the accessibility node will inevitably + // create issues as OpaqueNodes will be reused when DOM nodes are destroyed. Instead, we should + // make a monotonically increasing ID, and have some other way to retrieve it based on DOM node. fn to_accesskit_id(opaque: &OpaqueNode) -> accesskit::NodeId { accesskit::NodeId(opaque.0 as u64) } @@ -229,3 +244,25 @@ fn test_accessibility_update_add_some_nodes_twice() { } ); } + +static HTML_ELEMENT_ROLE_MAPPINGS: LazyLock> = LazyLock::new(|| { + [ + (local_name!("article"), Role::Article), + (local_name!("aside"), Role::Complementary), + (local_name!("body"), Role::RootWebArea), + (local_name!("footer"), Role::ContentInfo), + (local_name!("h1"), Role::Heading), + (local_name!("h2"), Role::Heading), + (local_name!("h3"), Role::Heading), + (local_name!("h4"), Role::Heading), + (local_name!("h5"), Role::Heading), + (local_name!("h6"), Role::Heading), + (local_name!("header"), Role::Banner), + (local_name!("hr"), Role::Splitter), + (local_name!("main"), Role::Main), + (local_name!("nav"), Role::Navigation), + (local_name!("p"), Role::Paragraph), + ] + .into_iter() + .collect() +}); diff --git a/components/servo/tests/accessibility.rs b/components/servo/tests/accessibility.rs index 0ecaea1154c..699f808f289 100644 --- a/components/servo/tests/accessibility.rs +++ b/components/servo/tests/accessibility.rs @@ -201,6 +201,63 @@ fn test_accessibility_after_navigate_and_back() { // TODO(accessibility): write test for resend a11y tree when clicking back or forward +#[test] +fn test_accessibility_basic_mapping() { + let servo_test = ServoTest::new_with_builder(|builder| { + let mut preferences = Preferences::default(); + preferences.accessibility_enabled = true; + builder.preferences(preferences) + }); + let delegate = Rc::new(WebViewDelegateImpl::default()); + + let mut element_role_pairs = VecDeque::from([ + ("article", Role::Article), + ("aside", Role::Complementary), + ("footer", Role::ContentInfo), + ("h1", Role::Heading), + ("h2", Role::Heading), + ("h3", Role::Heading), + ("h4", Role::Heading), + ("h5", Role::Heading), + ("h6", Role::Heading), + ("header", Role::Banner), + ("hr", Role::Splitter), + ("main", Role::Main), + ("nav", Role::Navigation), + ("p", Role::Paragraph), + ]); + + let mut url: String = "data:text/html,".to_owned(); + for (element, _) in element_role_pairs.iter() { + url.push_str(format!("<{element}>").as_str()); + } + let webview = WebViewBuilder::new(servo_test.servo(), servo_test.rendering_context.clone()) + .delegate(delegate.clone()) + .url(Url::parse(url.as_str()).unwrap()) + .build(); + + webview.set_accessibility_active(true); + + let load_webview = webview.clone(); + servo_test.spin(move || load_webview.load_status() != LoadStatus::Complete); + + let updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); + let tree = build_tree(updates); + let root = assert_tree_structure_and_get_root_web_area(&tree); + assert_eq!(root.children().len(), element_role_pairs.len()); + for child in root.children() { + let Some((tag, role)) = element_role_pairs.pop_front() else { + panic!("Number of children of root node should match number of tag/role pairs"); + }; + assert_eq!(child.data().html_tag(), Some(tag)); + assert_eq!(child.role(), role); + } + assert!( + element_role_pairs.is_empty(), + "Number of children of root node should match number of tag/role pairs" + ); +} + fn wait_for_min_updates( servo_test: &ServoTest, delegate: Rc, @@ -266,13 +323,8 @@ fn assert_tree_structure_and_get_root_web_area<'tree>( let graft_node = scroll_view_children[0]; assert!(graft_node.is_graft()); - let graft_node_children: Vec> = graft_node.children().collect(); - assert_eq!(graft_node_children.len(), 1); - - let root_web_area = graft_node_children[0]; - assert_eq!(root_web_area.role(), Role::RootWebArea); - - root_web_area + find_first_matching_node(graft_node, |node| node.role() == Role::RootWebArea) + .expect("Should have a RootWebArea") } fn find_first_matching_node(