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 `