From 314e5d6bb7f635b2786a4bbfc6e3f2661ed6dd3c Mon Sep 17 00:00:00 2001 From: sideshowbarker Date: Mon, 11 Nov 2024 16:21:05 +0900 Subject: [PATCH] LibWeb: Compute accessible names for hidden/hidden-but-referenced nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change implements full support for the “A. Hidden Not Referenced” step at https://w3c.github.io/accname/#step2A in the “Accessible Name and Description Computation” spec — including handling all hidden nodes that must be ignored, as well as handling hidden nodes that, for the purposes of accessible-name computation, must not be ignored (due to having aria-labelledby/aria-describedby references from other nodes). Otherwise, without this change, not all cases of hidden nodes get ignored as expected, while cases of nodes that are hidden but that have aria-labelledby/aria-describedby references from other nodes get unexpectedly ignored. --- Libraries/LibWeb/DOM/Element.cpp | 48 ++++ Libraries/LibWeb/DOM/Element.h | 6 + Libraries/LibWeb/DOM/Node.cpp | 67 +++-- .../name/comp_hidden_not_referenced.txt | 15 ++ .../name/comp_labelledby_hidden_nodes.txt | 37 +++ .../name/comp_hidden_not_referenced.html | 92 +++++++ .../name/comp_labelledby_hidden_nodes.html | 245 ++++++++++++++++++ 7 files changed, 489 insertions(+), 21 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/wpt-import/accname/name/comp_hidden_not_referenced.txt create mode 100644 Tests/LibWeb/Text/expected/wpt-import/accname/name/comp_labelledby_hidden_nodes.txt create mode 100644 Tests/LibWeb/Text/input/wpt-import/accname/name/comp_hidden_not_referenced.html create mode 100644 Tests/LibWeb/Text/input/wpt-import/accname/name/comp_labelledby_hidden_nodes.html diff --git a/Libraries/LibWeb/DOM/Element.cpp b/Libraries/LibWeb/DOM/Element.cpp index a7851cdeedc..65b3b11a60d 100644 --- a/Libraries/LibWeb/DOM/Element.cpp +++ b/Libraries/LibWeb/DOM/Element.cpp @@ -1874,6 +1874,54 @@ void Element::invalidate_style_after_attribute_change(FlyString const& attribute invalidate_style(StyleInvalidationReason::ElementAttributeChange); } +bool Element::is_hidden() const +{ + if (layout_node() == nullptr) + return true; + if (layout_node()->computed_values().visibility() == CSS::Visibility::Hidden || layout_node()->computed_values().visibility() == CSS::Visibility::Collapse || layout_node()->computed_values().content_visibility() == CSS::ContentVisibility::Hidden) + return true; + for (ParentNode const* self_or_ancestor = this; self_or_ancestor; self_or_ancestor = self_or_ancestor->parent_or_shadow_host()) { + if (self_or_ancestor->is_element() && static_cast(self_or_ancestor)->aria_hidden() == "true") + return true; + } + return false; +} + +bool Element::has_hidden_ancestor() const +{ + for (ParentNode const* self_or_ancestor = this; self_or_ancestor; self_or_ancestor = self_or_ancestor->parent_or_shadow_host()) { + if (self_or_ancestor->is_element() && static_cast(self_or_ancestor)->is_hidden()) + return true; + } + return false; +} + +bool Element::is_referenced() const +{ + bool is_referenced = false; + if (id().has_value()) { + root().for_each_in_subtree_of_type([&](auto& element) { + auto aria_data = MUST(Web::ARIA::AriaData::build_data(element)); + if (aria_data->aria_labelled_by_or_default().contains_slow(id().value())) { + is_referenced = true; + return TraversalDecision::Break; + } + return TraversalDecision::Continue; + }); + } + return is_referenced; +} + +bool Element::has_referenced_and_hidden_ancestor() const +{ + for (auto const* ancestor = parent_or_shadow_host(); ancestor; ancestor = ancestor->parent_or_shadow_host()) { + if (ancestor->is_element()) + if (auto const* element = static_cast(ancestor); element->is_referenced() && element->is_hidden()) + return true; + } + return false; +} + // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion bool Element::exclude_from_accessibility_tree() const { diff --git a/Libraries/LibWeb/DOM/Element.h b/Libraries/LibWeb/DOM/Element.h index 94caa57ef42..382e3b29393 100644 --- a/Libraries/LibWeb/DOM/Element.h +++ b/Libraries/LibWeb/DOM/Element.h @@ -348,6 +348,12 @@ public: virtual bool include_in_accessibility_tree() const override; + bool is_hidden() const; + bool has_hidden_ancestor() const; + + bool is_referenced() const; + bool has_referenced_and_hidden_ancestor() const; + void enqueue_a_custom_element_upgrade_reaction(HTML::CustomElementDefinition& custom_element_definition); void enqueue_a_custom_element_callback_reaction(FlyString const& callback_name, GC::MarkedVector arguments); diff --git a/Libraries/LibWeb/DOM/Node.cpp b/Libraries/LibWeb/DOM/Node.cpp index 211374b94c1..f43506df39d 100644 --- a/Libraries/LibWeb/DOM/Node.cpp +++ b/Libraries/LibWeb/DOM/Node.cpp @@ -2216,23 +2216,26 @@ ErrorOr Node::name_or_description(NameOrDescription target, Document con if (is_element()) { auto const* element = static_cast(this); auto role = element->role_or_default(); - bool is_referenced = false; - auto id = element->id(); - if (id.has_value()) { - this->root().for_each_in_inclusive_subtree_of_type([&](auto& element) { - auto aria_data = MUST(Web::ARIA::AriaData::build_data(element)); - if (aria_data->aria_labelled_by_or_default().contains_slow(id.value())) { - is_referenced = true; - return TraversalDecision::Break; - } - return TraversalDecision::Continue; - }); - } // 2. Compute the text alternative for the current node: - // A. If the current node is hidden and is not directly referenced by aria-labelledby or aria-describedby, nor directly referenced by a native host language text alternative element (e.g. label in HTML) or attribute, return the empty string. - // FIXME: Check for references - if (element->aria_hidden() == "true") - return String {}; + + // A. Hidden Not Referenced: If the current node is hidden and is: + // i. Not part of an aria-labelledby or aria-describedby traversal, where the node directly referenced by that + // relation was hidden. + // ii. Nor part of a native host language text alternative element (e.g. label in HTML) or attribute traversal, + // where the root of that traversal was hidden. + // Return the empty string. + // NOTE: Nodes with CSS properties display:none, visibility:hidden, visibility:collapse or + // content-visibility:hidden: They are considered hidden, as they match the guidelines "not perceivable" and + // "explicitly hidden". + // + // AD-HOC: We don’t implement this step here — because strictly implementing this would cause us to return early + // whenever encountering a node (element, actually) that “is hidden and is not directly referenced by + // aria-labelledby or aria-describedby”, without traversing down through that element’s subtree to see if it has + // (1) any descendant elements that are directly referenced and/or (2) any un-hidden nodes. So we instead (in + // substep G below) traverse upward through ancestor nodes of every text node, and check in that way to do the + // equivalent of what this step seems to have been intended to do. + // https://github.com/w3c/aria/issues/2387 + // B. Otherwise: // - if computing a name, and the current node has an aria-labelledby attribute that contains at least one valid IDREF, and the current node is not already part of an aria-labelledby traversal, // process its IDREFs in the order they occur: @@ -2260,6 +2263,7 @@ ErrorOr Node::name_or_description(NameOrDescription target, Document con // AD-HOC: The “For each IDREF” substep in the spec doesn’t seem to explicitly require the following // check for an aria-label value; but the “div group explicitly labelledby self and heading” subtest at // https://wpt.fyi/results/accname/name/comp_labelledby.html won’t pass unless we do this check. + // https://github.com/w3c/aria/issues/2388 if (target == NameOrDescription::Name && node->aria_label().has_value() && !node->aria_label()->is_empty() && !node->aria_label()->bytes_as_string_view().is_whitespace()) { total_accumulated_text.append(' '); total_accumulated_text.append(node->aria_label().value()); @@ -2275,6 +2279,13 @@ ErrorOr Node::name_or_description(NameOrDescription target, Document con total_accumulated_text.append(result); } // iii. Return the accumulated text. + // AD-HOC: This substep in the spec doesn’t seem to explicitly require the following check for an aria-label + // value; but the “button's hidden referenced name (visibility:hidden) with hidden aria-labelledby traversal + // falls back to aria-label” subtest at https://wpt.fyi/results/accname/name/comp_labelledby.html won’t pass + // unless we do this check. + // https://github.com/w3c/aria/issues/2388 + if (total_accumulated_text.string_view().is_whitespace() && target == NameOrDescription::Name && element->aria_label().has_value() && !element->aria_label()->is_empty() && !element->aria_label()->bytes_as_string_view().is_whitespace()) + return element->aria_label().release_value(); return total_accumulated_text.to_string(); } // C. Embedded Control: Otherwise, if the current node is a control embedded @@ -2297,6 +2308,7 @@ ErrorOr Node::name_or_description(NameOrDescription target, Document con // it ancestor