diff --git a/Cargo.lock b/Cargo.lock index 09d30e63a..bb1fc2713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1099,6 +1099,7 @@ dependencies = [ "markup5ever", "stylo", "taffy", + "test-that", "usvg", ] @@ -8017,6 +8018,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "253bcead4f3aa96243b0f8fa46f9010e87ca23bd5d0c723d474ff1d2417bbdf8" +[[package]] +name = "test-that" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291f228058142bae421e0b4b014a7b18edd6137629550cc15da37feed66a7db6" +dependencies = [ + "num-traits", + "regex", + "test-that-macro", +] + +[[package]] +name = "test-that-macro" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6453b0f4c66f6ed93ac9ababa97e347a22f5556180d19808bb6d796522eef5d" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "thin-vec" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index 7a9eb0fdd..6a5a2bd97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,6 +188,7 @@ smol_str = "0.3" bitflags = "2.8.0" bytemuck = "1" rayon = "1" +test-that = "0.5.2" thread_local = "1" [profile.profile] diff --git a/packages/blitz-dom/src/accessibility.rs b/packages/blitz-dom/src/accessibility.rs index e7e8b55fd..1b239c5a7 100644 --- a/packages/blitz-dom/src/accessibility.rs +++ b/packages/blitz-dom/src/accessibility.rs @@ -1,12 +1,23 @@ use crate::{BaseDocument, ElementData, Node as BlitzDomNode, local_name}; use accesskit::{Node as AccessKitNode, NodeId, Role, Tree, TreeId, 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(); 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)) @@ -55,6 +66,11 @@ impl BaseDocument { builder.set_role(role); builder.set_html_tag(name); + + // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion + if element_data.attr(local_name!("aria-hidden")) == Some("true") { + builder.set_hidden(); + } } else if node.is_text_node() { builder.set_role(Role::TextRun); builder.set_value(node.text_content()); @@ -67,6 +83,21 @@ impl BaseDocument { } } +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 role_from_name(name: &str) -> Option { match name { "alert" => Some(Role::Alert), diff --git a/tests/blitz-tests/Cargo.toml b/tests/blitz-tests/Cargo.toml index fee204d95..a12d2e015 100644 --- a/tests/blitz-tests/Cargo.toml +++ b/tests/blitz-tests/Cargo.toml @@ -31,6 +31,7 @@ accesskit = { workspace = true } markup5ever = { workspace = true } keyboard-types = { workspace = true } taffy = { workspace = true } +test-that = { workspace = true } usvg = { workspace = true } [lib] diff --git a/tests/blitz-tests/tests/accessibility_hidden.rs b/tests/blitz-tests/tests/accessibility_hidden.rs new file mode 100644 index 000000000..4f13d4eb4 --- /dev/null +++ b/tests/blitz-tests/tests/accessibility_hidden.rs @@ -0,0 +1,145 @@ +use accesskit::{Node as AccessKitNode, Role}; +use blitz_dom::DocumentConfig; +use blitz_html::{HtmlDocument, HtmlProvider}; +use blitz_traits::shell::{ColorScheme, Viewport}; +use std::sync::Arc; +use test_that::prelude::*; + +#[test] +fn includes_ordinary_div_as_node() -> TestResult<()> { + let mut document = + HtmlDocument::from_html("
", default_document_config()); + document.resolve(0.0); + + let tree_update = document.build_accessibility_tree(); + + verify_that!( + tree_update.nodes, + contains(( + anything(), + matches_pattern!(AccessKitNode { + role(): eq(Role::GenericContainer), + is_hidden(): eq(false), + }) + )) + ) +} + +#[test] +fn excludes_div_with_hidden_attribute() -> TestResult<()> { + let mut document = + HtmlDocument::from_html("", default_document_config()); + document.resolve(0.0); + + let tree_update = document.build_accessibility_tree(); + + verify_that!( + tree_update.nodes, + not(contains(( + anything(), + matches_pattern!(AccessKitNode { + role(): eq(Role::GenericContainer) + }) + ))) + ) +} + +#[test] +fn excludes_div_with_display_none() -> TestResult<()> { + let mut document = HtmlDocument::from_html( + r#"
"#, + default_document_config(), + ); + document.resolve(0.0); + + let tree_update = document.build_accessibility_tree(); + + verify_that!( + tree_update.nodes, + not(contains(( + anything(), + matches_pattern!(AccessKitNode { + role(): eq(Role::GenericContainer) + }) + ))) + ) +} + +#[test] +fn excludes_div_with_visibility_hidden() -> TestResult<()> { + let mut document = HtmlDocument::from_html( + r#"
"#, + default_document_config(), + ); + document.resolve(0.0); + + let tree_update = document.build_accessibility_tree(); + + verify_that!( + tree_update.nodes, + not(contains(( + anything(), + matches_pattern!(AccessKitNode { + role(): eq(Role::GenericContainer) + }) + ))) + ) +} + +#[test] +fn sets_hidden_flag_on_element_with_aria_hidden_attribute() -> TestResult<()> { + let mut document = HtmlDocument::from_html( + r#""#, + default_document_config(), + ); + document.resolve(0.0); + + let tree_update = document.build_accessibility_tree(); + + verify_that!( + tree_update.nodes, + contains(( + anything(), + matches_pattern!(AccessKitNode { + role(): eq(Role::GenericContainer), + is_hidden(): eq(true), + }) + )) + ) +} + +#[test] +fn excludes_child_element_of_hidden_element() -> TestResult<()> { + let mut document = HtmlDocument::from_html( + r#" + + + + + "#, + default_document_config(), + ); + document.resolve(0.0); + + let tree_update = document.build_accessibility_tree(); + + verify_that!( + tree_update.nodes, + not(contains(( + anything(), + matches_pattern!(AccessKitNode { + role(): eq(Role::Button), + }) + ))) + ) +} + +fn default_document_config() -> DocumentConfig { + DocumentConfig { + viewport: Some(Viewport::new(800, 600, 1.0, ColorScheme::Light)), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + } +} diff --git a/tests/blitz-tests/tests/accessibility_roles.rs b/tests/blitz-tests/tests/accessibility_roles.rs index 494d548dd..24de01cf7 100644 --- a/tests/blitz-tests/tests/accessibility_roles.rs +++ b/tests/blitz-tests/tests/accessibility_roles.rs @@ -192,7 +192,7 @@ fn a_semantic_page_has_no_unknown_elements() { "##; - // , and have no roles of their own. Everything else in + // and have no roles of their own. Everything else in // this document should map to something an assistive technology can use. - assert_eq!(unknown_tags(html), vec!["body", "head", "html"]); + assert_eq!(unknown_tags(html), vec!["body", "html"]); }