Skip to content

Commit

Permalink
LibWeb: Compute accessible names for hidden/hidden-but-referenced nodes
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sideshowbarker committed Nov 16, 2024
1 parent 98dadb0 commit ff18f72
Show file tree
Hide file tree
Showing 7 changed files with 478 additions and 20 deletions.
48 changes: 48 additions & 0 deletions Libraries/LibWeb/DOM/Element.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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() != nullptr) && (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<DOM::Element const*>(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<DOM::Element const*>(self_or_ancestor)->is_hidden())
return true;
}
return false;
}

bool Element::is_referenced() const
{
bool is_referenced = false;
if (id().has_value()) {
this->root().for_each_in_inclusive_subtree_of_type<HTML::HTMLElement>([&](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<DOM::Element const*>(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
{
Expand Down
6 changes: 6 additions & 0 deletions Libraries/LibWeb/DOM/Element.h
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ class Element

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<JS::Value> arguments);

Expand Down
55 changes: 35 additions & 20 deletions Libraries/LibWeb/DOM/Node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2195,23 +2195,25 @@ ErrorOr<String> Node::name_or_description(NameOrDescription target, Document con
if (is_element()) {
auto const* element = static_cast<DOM::Element const*>(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<HTML::HTMLElement>([&](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.

// 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:
Expand Down Expand Up @@ -2350,8 +2352,10 @@ ErrorOr<String> Node::name_or_description(NameOrDescription target, Document con
// with the spec requirements — and if not, then add handling for it here.
}

// F. Otherwise, if the current node's role allows name from content, or if the current node is referenced by aria-labelledby, aria-describedby, or is a native host language text alternative element (e.g. label in HTML), or is a descendant of a native host language text alternative element:
if ((role.has_value() && ARIA::allows_name_from_content(role.value())) || is_referenced || is_descendant == IsDescendant::Yes) {
// F. Name From Content: Otherwise, if the current node's role allows name from content, or if the current node
// is referenced by aria-labelledby, aria-describedby, or is a native host language text alternative element
// (e.g. label in HTML), or is a descendant of a native host language text alternative element:
if ((role.has_value() && ARIA::allows_name_from_content(role.value())) || element->is_referenced() || is_descendant == IsDescendant::Yes) {
// i. Set the accumulated text to the empty string.
total_accumulated_text.clear();
// ii. Name From Generated Content: Check for CSS generated textual content associated with the current node and include
Expand Down Expand Up @@ -2416,13 +2420,24 @@ ErrorOr<String> Node::name_or_description(NameOrDescription target, Document con
}

// G. Text Node: Otherwise, if the current node is a Text Node, return its textual contents.
if (is_text()) {
// AD-HOC: The spec doesn’t require ascending through the parent node and ancestor nodes of every text node we
// reach — the way we’re doing there. But we implement it this way because the spec algorithm as written doesn’t
// appear to achieve what it seems to be intended to achieve. Specifically, the spec algorithm as written doesn’t
// cause traversal through element subtrees in way that’s necessary to check for descendants that are referenced by
// aria-labelledby or aria-describedby and/or un-hidden. See the comment for substep A above.
if (is_text() && (parent_element()->is_referenced() || !parent_element()->is_hidden() || !parent_element()->has_hidden_ancestor() || parent_element()->has_referenced_and_hidden_ancestor())) {
if (layout_node() && layout_node()->is_text_node())
return verify_cast<Layout::TextNode>(layout_node())->text_for_rendering();
return text_content().value();
}

// TODO: H. Otherwise, if the current node is a descendant of an element whose Accessible Name or Accessible Description is being computed, and contains descendants, proceed to 2F.i.
// H. Otherwise, if the current node is a descendant of an element whose Accessible Name or Accessible Description
// is being computed, and contains descendants, proceed to 2F.i.
// AD-HOC: We don’t implement this step here — because is essentially unreachable code in the spec algorithm.
// We could never get here without descending through every subtree of an element whose Accessible Name or
// Accessible Description is being computed. And in our implementation of substep F about, we’re anyway already
// recursively descending through all the child nodes of every element whose Accessible Name or Accessible
// Description is being computed, in a way that never leads to this substep H every being hit.

// I. Otherwise, if the current node has a Tooltip attribute, return its value.
// https://www.w3.org/TR/accname-1.2/#dfn-tooltip-attribute
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Summary

Harness status: OK

Rerun

Found 5 tests

5 Pass
Details
Result Test Name MessagePass button containing a rendered, unreferenced element that is aria-hidden=true, an unreferenced element with the hidden host language attribute, and an unreferenced element that is unconditionally rendered
Pass button labelled by element that is aria-hidden=true
Pass button labelled by element with the hidden host language attribute
Pass link labelled by elements with assorted visibility and a11y tree exposure
Pass heading with name from content, containing element that is visibility:hidden with nested content that is visibility:visible
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
Summary

Harness status: OK

Rerun

Found 27 tests

27 Pass
Details
Result Test Name MessagePass button with aria-labelledby using display:none hidden span (with nested span)
Pass button with aria-labelledby using display:none hidden span (with nested spans, depth 2)
Pass button with aria-labelledby using span without display:none (with nested display:none spans, depth 2)
Pass button with aria-labelledby using display:none hidden span (with nested sibling spans)
Pass button with aria-labelledby using span without display:none (with nested display:none sibling spans)
Pass button with aria-labelledby using span with display:none (with nested display:inline sibling spans)
Pass button with aria-labelledby using visibility:hidden span (with nested span)
Pass button with aria-labelledby using visibility:hidden span (with nested spans, depth 2)
Pass button with aria-labelledby using span without visibility:hidden (with nested visibility:hidden spans, depth 2)
Pass button with aria-labelledby using visibility:hidden hidden span (with nested sibling spans)
Pass button with aria-labelledby using span without visibility:hidden (with nested visibility:hidden sibling spans)
Pass button with aria-labelledby using span with visibility:hidden (with nested visibility:visible sibling spans)
Pass button with aria-labelledby using visibility:collapse span (with nested span)
Pass button with aria-labelledby using visibility:collapse span (with nested spans, depth 2)
Pass button with aria-labelledby using span without visibility:collapse (with nested visibility:visible spans, depth 2)
Pass button with aria-labelledby using visibility:collapse span (with nested sibling spans)
Pass button with aria-labelledby using span without visibility:collapse (with nested visibility:collapse sibling spans)
Pass button with aria-labelledby using span with visibility:collapse (with nested visible sibling spans)
Pass button with aria-labelledby using aria-hidden span (with nested span)
Pass button with aria-labelledby using aria-hidden span (with nested spans, depth 2)
Pass button with aria-labelledby using span without aria-hidden (with nested aria-hidden spans, depth 2)
Pass button with aria-labelledby using aria-hidden hidden span (with nested sibling spans)
Pass button with aria-labelledby using HTML5 hidden span (with nested span)
Pass button with aria-labelledby using HTML5 hidden span (with nested spans, depth 2)
Pass button with aria-labelledby using span without HTML5 hidden (with nested HTML5 hidden spans, depth 2)
Pass button with aria-labelledby using HTML5 hidden span (with nested hidden sibling spans)
Pass button with aria-labelledby using span without HTML5 hidden (with nested hidden sibling spans)
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Name Comp: Hidden Not Referenced</title>
<script src="../../resources/testharness.js"></script>
<script src="../../resources/testharnessreport.js"></script>
<script src="../../resources/testdriver.js"></script>
<script src="../../resources/testdriver-vendor.js"></script>
<script src="../../resources/testdriver-actions.js"></script>
<script src="../../wai-aria/scripts/aria-utils.js"></script>
</head>
<body>

<p>Tests the <a href="https://w3c.github.io/accname/#comp_hidden_not_referenced">#comp_hidden_not_referenced</a> portions of the AccName <em>Name Computation</em> algorithm.</p>

<button
class="ex"
data-expectedlabel="visible to all users"
data-testname="button containing a rendered, unreferenced element that is aria-hidden=true, an unreferenced element with the hidden host language attribute, and an unreferenced element that is unconditionally rendered"
>
<span aria-hidden="true">hidden,</span>
<span hidden>hidden from all users,</span>
<span>visible to all users</span>
</button>

<button
class="ex"
data-expectedlabel="hidden but referenced,"
data-testname="button labelled by element that is aria-hidden=true"
aria-labelledby="button-label-2"
>
<span aria-hidden="true" id="button-label-2">hidden but referenced,</span>
<span hidden>hidden from all users,</span>
<span>visible to all users</span>
</button>

<button
class="ex"
data-expectedlabel="hidden from all users but referenced,"
data-testname="button labelled by element with the hidden host language attribute"
aria-labelledby="button-label-3"
>
<span aria-hidden="true">hidden,</span>
<span hidden id="button-label-3">hidden from all users but referenced,</span>
<span>visible to all users</span>
</button>

<a
class="ex"
data-testname="link labelled by elements with assorted visibility and a11y tree exposure"
data-expectedlabel="visible to all users, hidden but referenced, hidden from all users but referenced"
href="#"
aria-labelledby="link-label-1a link-label-1b link-label-1c"
>
<span id="link-label-1a">
<span>visible to all users,</span>
<span aria-hidden="true">hidden,</span>
</span>
<span aria-hidden="true" id="link-label-1b">hidden but referenced,</span>
<span hidden id="link-label-1c">hidden from all users but referenced</span>
</a>

<h2
class="ex"
data-testname="heading with name from content, containing element that is visibility:hidden with nested content that is visibility:visible"
data-expectedlabel="visible to all users, un-hidden for all users"
>
visible to all users,
<span style="visibility: hidden;">
hidden from all users,
<span style="visibility: visible;">un-hidden for all users</span>
</span>
</h2>

<!-- TODO: Test cases once https://github.com/w3c/aria/issues/1256 resolved: -->
<!-- - button labelled by an element that is aria-hidden=true which contains a nested child that is aria-hidden=false -->
<!-- - button labelled by an element that is aria-hidden=false which belongs to a parent that is aria-hidden=true -->
<!-- - heading with name from content, containing rendered content that is aria-hidden=true with nested, rendered content that is aria-hidden=false -->
<!-- - heading with name from content, containing element with the hidden host language attribute with nested content that is aria-hidden=false -->

<!-- TODO: New test case?
<!-- What is the expectation for a details element when it’s given an -->
<!-- explicit role that allows name from contents (e.g., `comment`) -->
<!-- but is also not in the open state, and therefore has contents -->
<!-- that are both not rendered and excluded from the a11y tree. -->

<script>
AriaUtils.verifyLabelsBySelector(".ex");
</script>
</body>
</html>
Loading

0 comments on commit ff18f72

Please sign in to comment.