LibWeb: Implement Accessible Name and Description Calculation

This is an initial implementation of the accname standard. There is
still some of the algorithm left unimplemented that we will need
to implement in the future. However, as is, this implementation is
sufficient for basic pages.
This commit is contained in:
Jonah 2023-02-05 11:21:59 -06:00 committed by Linus Groh
parent fc3ee471ed
commit da5c9189b2
Notes: sideshowbarker 2024-07-17 00:01:20 +09:00
2 changed files with 254 additions and 0 deletions

View file

@ -6,10 +6,12 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/HashTable.h>
#include <AK/IDAllocator.h>
#include <AK/StringBuilder.h>
#include <LibJS/Heap/DeferGC.h>
#include <LibJS/Runtime/FunctionObject.h>
#include <LibRegex/Regex.h>
#include <LibWeb/Bindings/MainThreadVM.h>
#include <LibWeb/Bindings/NodePrototype.h>
#include <LibWeb/DOM/Comment.h>
@ -32,6 +34,7 @@
#include <LibWeb/HTML/HTMLStyleElement.h>
#include <LibWeb/HTML/Origin.h>
#include <LibWeb/HTML/Parser/HTMLParser.h>
#include <LibWeb/Infra/CharacterTypes.h>
#include <LibWeb/Layout/InitialContainingBlock.h>
#include <LibWeb/Layout/Node.h>
#include <LibWeb/Layout/TextNode.h>
@ -1599,4 +1602,239 @@ void Node::build_accessibility_tree(AccessibilityTreeNode& parent) const
}
}
// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_te
ErrorOr<String> Node::name_or_description(NameOrDescription target, Document const& document, HashTable<i32>& visited_nodes) const
{
// The text alternative for a given element is computed as follows:
// 1. Set the root node to the given element, the current node to the root node, and the total accumulated text to the empty string (""). If the root node's role prohibits naming, return the empty string ("").
auto const* root_node = this;
auto const* current_node = root_node;
StringBuilder total_accumulated_text;
visited_nodes.set(id());
if (is_element()) {
auto const* element = static_cast<DOM::Element const*>(this);
// 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" || !layout_node())
return String {};
// 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:
// - or, if computing a description, and the current node has an aria-describedby attribute that contains at least one valid IDREF, and the current node is not already part of an aria-describedby traversal,
// process its IDREFs in the order they occur:
if ((target == NameOrDescription::Name && Node::first_valid_id(element->aria_labelled_by(), document).has_value())
|| (target == NameOrDescription::Description && Node::first_valid_id(element->aria_described_by(), document).has_value())) {
// i. Set the accumulated text to the empty string.
total_accumulated_text.clear();
Vector<StringView> id_list;
if (target == NameOrDescription::Name) {
id_list = element->aria_labelled_by().split_view(Infra::is_ascii_whitespace);
} else {
id_list = element->aria_described_by().split_view(Infra::is_ascii_whitespace);
}
// ii. For each IDREF:
for (auto const& id_ref : id_list) {
auto node = document.get_element_by_id(id_ref);
if (!node)
continue;
if (visited_nodes.contains(node->id()))
continue;
// a. Set the current node to the node referenced by the IDREF.
current_node = node;
// b. Compute the text alternative of the current node beginning with step 2. Set the result to that text alternative.
auto result = TRY(node->name_or_description(target, document, visited_nodes));
// c. Append the result, with a space, to the accumulated text.
TRY(Node::append_with_space(total_accumulated_text, result));
}
// iii. Return the accumulated text.
return total_accumulated_text.to_string();
}
// C. Otherwise, if computing a name, and if the current node has an aria-label attribute whose value is not the empty string, nor, when trimmed of white space, is not the empty string:
if (target == NameOrDescription::Name && !element->aria_label().is_empty() && !element->aria_label().trim_whitespace().is_empty()) {
// TODO: - If traversal of the current node is due to recursion and the current node is an embedded control as defined in step 2E, ignore aria-label and skip to rule 2E.
// - Otherwise, return the value of aria-label.
return String::from_deprecated_string(element->aria_label());
}
// TODO: D. Otherwise, if the current node's native markup provides an attribute (e.g. title) or element (e.g. HTML label) that defines a text alternative,
// return that alternative in the form of a flat string as defined by the host language, unless the element is marked as presentational (role="presentation" or role="none").
// TODO: E. Otherwise, if the current node is a control embedded within the label (e.g. the label element in HTML or any element directly referenced by aria-labelledby) for another widget, where the user can adjust the embedded
// control's value, then include the embedded control as part of the text alternative in the following manner:
// - If the embedded control has role textbox, return its value.
// - If the embedded control has role menu button, return the text alternative of the button.
// - If the embedded control has role combobox or listbox, return the text alternative of the chosen option.
// - If the embedded control has role range (e.g., a spinbutton or slider):
// - If the aria-valuetext property is present, return its value,
// - Otherwise, if the aria-valuenow property is present, return its value,
// - Otherwise, use the value as specified by a host language attribute.
// 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:
auto role = element->role_or_default();
if (role.has_value() && ARIA::allows_name_from_content(role.value())) {
// i. Set the accumulated text to the empty string.
total_accumulated_text.clear();
// ii. Check for CSS generated textual content associated with the current node and include it in the accumulated text. The CSS :before and :after pseudo elements [CSS2] can provide textual content for elements that have a content model.
auto before = element->get_pseudo_element_node(CSS::Selector::PseudoElement::Before);
auto after = element->get_pseudo_element_node(CSS::Selector::PseudoElement::After);
// - For :before pseudo elements, User agents MUST prepend CSS textual content, without a space, to the textual content of the current node.
if (before)
TRY(Node::prepend_without_space(total_accumulated_text, before->computed_values().content().data));
// - For :after pseudo elements, User agents MUST append CSS textual content, without a space, to the textual content of the current node.
if (after)
TRY(Node::append_without_space(total_accumulated_text, after->computed_values().content().data));
// iii. For each child node of the current node:
element->for_each_child([&total_accumulated_text, current_node, target, &document, visited_nodes](
DOM::Node const& child_node) mutable {
if (visited_nodes.contains(child_node.id()))
return;
// a. Set the current node to the child node.
current_node = &child_node;
// b. Compute the text alternative of the current node beginning with step 2. Set the result to that text alternative.
auto result = MUST(current_node->name_or_description(target, document, visited_nodes));
// c. Append the result to the accumulated text.
total_accumulated_text.append(result);
});
// iv. Return the accumulated text.
return total_accumulated_text.to_string();
// Important: Each node in the subtree is consulted only once. If text has been collected from a descendant, but is referenced by another IDREF in some descendant node, then that second, or subsequent, reference is not followed. This is done to avoid infinite loops.
}
}
// G. Otherwise, if the current node is a Text node, return its textual contents.
if (is_text()) {
auto const* text_node = static_cast<DOM::Text const*>(this);
return String::from_deprecated_string(text_node->data());
}
// 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.
// I. Otherwise, if the current node has a Tooltip attribute, return its value.
// https://www.w3.org/TR/accname-1.2/#dfn-tooltip-attribute
// Any host language attribute that would result in a user agent generating a tooltip such as in response to a mouse hover in desktop user agents.
// FIXME: Support SVG tooltips and CSS tooltips
if (is<HTML::HTMLElement>(this)) {
auto const* element = static_cast<HTML::HTMLElement const*>(this);
auto tooltip = element->title();
if (!tooltip.is_empty() && !tooltip.is_null())
return String::from_deprecated_string(tooltip);
}
// Append the result of each step above, with a space, to the total accumulated text.
//
// After all steps are completed, the total accumulated text is used as the accessible name or accessible description of the element that initiated the computation.
return total_accumulated_text.to_string();
}
// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_name
ErrorOr<String> Node::accessible_name(Document const& document) const
{
HashTable<i32> visited_nodes;
// User agents MUST compute an accessible name using the rules outlined below in the section titled Accessible Name and Description Computation.
return name_or_description(NameOrDescription::Name, document, visited_nodes);
}
// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_description
ErrorOr<String> Node::accessible_description(Document const& document) const
{
// If aria-describedby is present, user agents MUST compute the accessible description by concatenating the text alternatives for elements referenced by an aria-describedby attribute on the current element.
// The text alternatives for the referenced elements are computed using a number of methods, outlined below in the section titled Accessible Name and Description Computation.
if (is_element()) {
HashTable<i32> visited_nodes;
StringBuilder builder;
auto const* element = static_cast<Element const*>(this);
auto id_list = element->aria_described_by().split_view(Infra::is_ascii_whitespace);
for (auto const& id : id_list) {
if (auto description_element = document.get_element_by_id(id)) {
auto description = TRY(
description_element->name_or_description(NameOrDescription::Description, document,
visited_nodes));
if (!description.is_empty()) {
if (builder.is_empty()) {
builder.append(description);
} else {
builder.append(" "sv);
builder.append(description);
}
}
}
}
return builder.to_string();
}
return String {};
}
Optional<StringView> Node::first_valid_id(DeprecatedString const& value, Document const& document)
{
auto id_list = value.split_view(Infra::is_ascii_whitespace);
for (auto const& id : id_list) {
if (document.get_element_by_id(id))
return id;
}
return {};
}
// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_te
ErrorOr<void> Node::append_without_space(StringBuilder x, StringView const& result)
{
// - If X is empty, copy the result to X.
// - If X is non-empty, copy the result to the end of X.
TRY(x.try_append(result));
return {};
}
// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_te
ErrorOr<void> Node::append_with_space(StringBuilder x, StringView const& result)
{
// - If X is empty, copy the result to X.
if (x.is_empty()) {
TRY(x.try_append(result));
} else {
// - If X is non-empty, add a space to the end of X and then copy the result to X after the space.
TRY(x.try_append(" "sv));
TRY(x.try_append(result));
}
return {};
}
// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_te
ErrorOr<void> Node::prepend_without_space(StringBuilder x, StringView const& result)
{
// - If X is empty, copy the result to X.
if (x.is_empty()) {
x.append(result);
} else {
// - If X is non-empty, copy the result to the start of X.
auto temp = TRY(x.to_string());
x.clear();
TRY(x.try_append(result));
TRY(x.try_append(temp));
}
return {};
}
// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_te
ErrorOr<void> Node::prepend_with_space(StringBuilder x, StringView const& result)
{
// - If X is empty, copy the result to X.
if (x.is_empty()) {
TRY(x.try_append(result));
} else {
// - If X is non-empty, copy the result to the start of X, and add a space after the copy.
auto temp = TRY(x.to_string());
x.clear();
TRY(x.try_append(result));
TRY(x.try_append(" "sv));
TRY(x.try_append(temp));
}
return {};
}
}

View file

@ -35,6 +35,11 @@ enum class NodeType : u16 {
NOTATION_NODE = 12
};
enum class NameOrDescription {
Name,
Description
};
struct GetRootNodeOptions {
bool composed { false };
};
@ -617,6 +622,9 @@ public:
return false;
}
ErrorOr<String> accessible_name(Document const&) const;
ErrorOr<String> accessible_description(Document const&) const;
protected:
Node(JS::Realm&, Document&, NodeType);
Node(Document&, NodeType);
@ -638,6 +646,8 @@ protected:
void build_accessibility_tree(AccessibilityTreeNode& parent) const;
ErrorOr<String> name_or_description(NameOrDescription, Document const&, HashTable<i32>&) const;
private:
void queue_tree_mutation_record(JS::NonnullGCPtr<NodeList> added_nodes, JS::NonnullGCPtr<NodeList> removed_nodes, Node* previous_sibling, Node* next_sibling);
@ -645,6 +655,12 @@ private:
void append_child_impl(JS::NonnullGCPtr<Node>);
void remove_child_impl(JS::NonnullGCPtr<Node>);
static Optional<StringView> first_valid_id(DeprecatedString const&, Document const&);
static ErrorOr<void> append_without_space(StringBuilder, StringView const&);
static ErrorOr<void> append_with_space(StringBuilder, StringView const&);
static ErrorOr<void> prepend_without_space(StringBuilder, StringView const&);
static ErrorOr<void> prepend_with_space(StringBuilder, StringView const&);
JS::GCPtr<Node> m_parent;
JS::GCPtr<Node> m_first_child;
JS::GCPtr<Node> m_last_child;