LibWeb: Implement element-referencing ARIA attributes

There are ARIA attributes, e.g. ariaControlsElements, which refer to a
list of elements by their ID. For example:

    <div aria-controls="item1 item2">

The div.ariaControlsElements attribute would be a list of elements whose
ID matches the values in the aria-controls attribute.
This commit is contained in:
Timothy Flynn 2025-04-24 13:41:12 -04:00 committed by Tim Ledbetter
commit f985ac8884
Notes: github-actions[bot] 2025-04-25 00:21:23 +00:00
9 changed files with 3354 additions and 28 deletions

View file

@ -230,4 +230,17 @@ Vector<String> ARIAMixin::parse_id_reference_list(Optional<String> const& id_lis
return result;
}
#define __ENUMERATE_ARIA_ATTRIBUTE(attribute, referencing_attribute) \
Optional<Vector<WeakPtr<DOM::Element>>> const& ARIAMixin::attribute() const \
{ \
return m_##attribute; \
} \
\
void ARIAMixin::set_##attribute(Optional<Vector<WeakPtr<DOM::Element>>> value) \
{ \
m_##attribute = move(value); \
}
ENUMERATE_ARIA_ELEMENT_LIST_REFERENCING_ATTRIBUTES
#undef __ENUMERATE_ARIA_ATTRIBUTE
}

View file

@ -15,6 +15,15 @@
namespace Web::ARIA {
#define ENUMERATE_ARIA_ELEMENT_LIST_REFERENCING_ATTRIBUTES \
__ENUMERATE_ARIA_ATTRIBUTE(aria_controls_elements, aria_controls) \
__ENUMERATE_ARIA_ATTRIBUTE(aria_described_by_elements, aria_described_by) \
__ENUMERATE_ARIA_ATTRIBUTE(aria_details_elements, aria_details) \
__ENUMERATE_ARIA_ATTRIBUTE(aria_error_message_elements, aria_error_message) \
__ENUMERATE_ARIA_ATTRIBUTE(aria_flow_to_elements, aria_flow_to) \
__ENUMERATE_ARIA_ATTRIBUTE(aria_labelled_by_elements, aria_labelled_by) \
__ENUMERATE_ARIA_ATTRIBUTE(aria_owns_elements, aria_owns)
class ARIAMixin {
public:
virtual ~ARIAMixin();
@ -51,6 +60,12 @@ public:
GC::Ptr<DOM::Element> aria_active_descendant_element() { return m_aria_active_descendant_element; }
void set_aria_active_descendant_element(GC::Ptr<DOM::Element> value) { m_aria_active_descendant_element = value; }
#define __ENUMERATE_ARIA_ATTRIBUTE(attribute, referencing_attribute) \
Optional<Vector<WeakPtr<DOM::Element>>> const& attribute() const; \
void set_##attribute(Optional<Vector<WeakPtr<DOM::Element>>> value);
ENUMERATE_ARIA_ELEMENT_LIST_REFERENCING_ATTRIBUTES
#undef __ENUMERATE_ARIA_ATTRIBUTE
protected:
ARIAMixin();
@ -60,6 +75,11 @@ protected:
private:
GC::Ptr<DOM::Element> m_aria_active_descendant_element;
#define __ENUMERATE_ARIA_ATTRIBUTE(attribute, referencing_attribute) \
Optional<Vector<WeakPtr<DOM::Element>>> m_##attribute;
ENUMERATE_ARIA_ELEMENT_LIST_REFERENCING_ATTRIBUTES
#undef __ENUMERATE_ARIA_ATTRIBUTE
};
}

View file

@ -12,21 +12,28 @@ interface mixin ARIAMixin {
[CEReactions] attribute DOMString? ariaColIndex;
[CEReactions] attribute DOMString? ariaColIndexText;
[CEReactions] attribute DOMString? ariaColSpan;
[Reflect=aria-controls, CEReactions] attribute sequence<Element>? ariaControlsElements; // FIXME: Should `FrozenArray<Element>?`
[CEReactions] attribute DOMString? ariaCurrent;
[Reflect=aria-describedby, CEReactions] attribute sequence<Element>? ariaDescribedByElements; // FIXME: Should `FrozenArray<Element>?`
[CEReactions] attribute DOMString? ariaDescription;
[Reflect=aria-details, CEReactions] attribute sequence<Element>? ariaDetailsElements; // FIXME: Should `FrozenArray<Element>?`
[CEReactions] attribute DOMString? ariaDisabled;
[Reflect=aria-errormessage, CEReactions] attribute sequence<Element>? ariaErrorMessageElements; // FIXME: Should `FrozenArray<Element>?`
[CEReactions] attribute DOMString? ariaExpanded;
[Reflect=aria-flowto, CEReactions] attribute sequence<Element>? ariaFlowToElements; // FIXME: Should `FrozenArray<Element>?`
[CEReactions] attribute DOMString? ariaHasPopup;
[CEReactions] attribute DOMString? ariaHidden;
[CEReactions] attribute DOMString? ariaInvalid;
[CEReactions] attribute DOMString? ariaKeyShortcuts;
[CEReactions] attribute DOMString? ariaLabel;
[Reflect=aria-labelledby, CEReactions] attribute sequence<Element>? ariaLabelledByElements; // FIXME: Should `FrozenArray<Element>?`
[CEReactions] attribute DOMString? ariaLevel;
[CEReactions] attribute DOMString? ariaLive;
[CEReactions] attribute DOMString? ariaModal;
[CEReactions] attribute DOMString? ariaMultiLine;
[CEReactions] attribute DOMString? ariaMultiSelectable;
[CEReactions] attribute DOMString? ariaOrientation;
[Reflect=aria-owns, CEReactions] attribute sequence<Element>? ariaOwnsElements; // FIXME: Should `FrozenArray<Element>?`
[CEReactions] attribute DOMString? ariaPlaceholder;
[CEReactions] attribute DOMString? ariaPosInSet;
[CEReactions] attribute DOMString? ariaPressed;

View file

@ -430,6 +430,60 @@ Vector<String> Element::get_attribute_names() const
return names;
}
// https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#attr-associated-elements
Optional<GC::RootVector<GC::Ref<DOM::Element>>> Element::get_the_attribute_associated_elements(FlyString const& content_attribute, Optional<Vector<WeakPtr<DOM::Element>>> const& explicitly_set_attribute_elements) const
{
// 1. Let elements be an empty list.
GC::RootVector<GC::Ref<DOM::Element>> elements(heap());
// 2. Let element be the result of running reflectedTarget's get the element.
auto const& element = *this;
// 3. If reflectedTarget's explicitly set attr-elements is not null:
if (explicitly_set_attribute_elements.has_value()) {
// 1. For each attrElement in reflectedTarget's explicitly set attr-elements:
for (auto const& attribute_element : *explicitly_set_attribute_elements) {
// 1. If attrElement is not a descendant of any of element's shadow-including ancestors, then continue.
if (!attribute_element || &attribute_element->root() != &element.shadow_including_root())
continue;
// 2. Append attrElement to elements.
elements.append(*attribute_element);
}
}
// 4. Otherwise:
else {
// 1. Let contentAttributeValue be the result of running reflectedTarget's get the content attribute.
auto content_attribute_value = element.get_attribute(content_attribute);
// 2. If contentAttributeValue is null, then return null.
if (!content_attribute_value.has_value())
return {};
// 3. Let tokens be contentAttributeValue, split on ASCII whitespace.
auto tokens = content_attribute_value->bytes_as_string_view().split_view_if(Infra::is_ascii_whitespace);
// 4. For each id of tokens:
for (auto id : tokens) {
// 1. Let candidate be the first element, in tree order, that meets the following criteria:
// * candidate's root is the same as element's root;
// * candidate's ID is id; and
// * candidate implements T.
auto candidate = element.document().get_element_by_id(MUST(FlyString::from_utf8(id)));
// 2. If no such element exists, then continue.
if (!candidate)
continue;
// 3. Append candidate to elements.
elements.append(*candidate);
}
}
// 5. Return elements.
return elements;
}
GC::Ptr<Layout::Node> Element::create_layout_node(GC::Ref<CSS::ComputedProperties> style)
{
if (local_name() == "noscript" && document().is_scripting_enabled())
@ -3609,6 +3663,17 @@ void Element::attribute_changed(FlyString const& local_name, Optional<String> co
// Set element's explicitly set attr-element to null.
set_aria_active_descendant_element({});
}
// https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#reflecting-content-attributes-in-idl-attributes:concept-element-attributes-change-ext-2
// 1. If localName is not attr or namespace is not null, then return.
// 2. Set element's explicitly set attr-elements to null.
#define __ENUMERATE_ARIA_ATTRIBUTE(attribute, referencing_attribute) \
else if (local_name == ARIA::AttributeNames::referencing_attribute && !namespace_.has_value()) \
{ \
set_##attribute({}); \
}
ENUMERATE_ARIA_ELEMENT_LIST_REFERENCING_ATTRIBUTES
#undef __ENUMERATE_ARIA_ATTRIBUTE
}
auto Element::ensure_custom_element_reaction_queue() -> CustomElementReactionQueue&

View file

@ -163,6 +163,8 @@ public:
GC::Ptr<Attr> get_attribute_node(FlyString const& name) const;
GC::Ptr<Attr> get_attribute_node_ns(Optional<FlyString> const& namespace_, FlyString const& name) const;
Optional<GC::RootVector<GC::Ref<DOM::Element>>> get_the_attribute_associated_elements(FlyString const& content_attribute, Optional<Vector<WeakPtr<DOM::Element>>> const& explicitly_set_attribute_elements) const;
DOMTokenList* class_list();
WebIDL::ExceptionOr<GC::Ref<ShadowRoot>> attach_shadow(ShadowRootInit init);

View file

@ -172,6 +172,18 @@ static StringView sequence_storage_type_to_cpp_storage_type_name(SequenceStorage
}
}
static bool is_nullable_sequence_of_single_type(Type const& type, StringView type_name)
{
if (!type.is_nullable() || !type.is_sequence())
return false;
auto const& parameters = type.as_parameterized().parameters();
if (parameters.size() != 1)
return false;
return parameters.first()->name() == type_name;
}
CppType idl_type_name_to_cpp_type(Type const& type, Interface const& interface);
static ByteString union_type_to_variant(UnionType const& union_type, Interface const& interface)
@ -1024,21 +1036,15 @@ static void generate_to_cpp(SourceGenerator& generator, ParameterType& parameter
// 3. If method is undefined, throw a TypeError.
// 4. Return the result of creating a sequence from V and method.
if (optional) {
if (optional || parameter.type->is_nullable()) {
auto sequence_cpp_type = idl_type_name_to_cpp_type(parameterized_type.parameters().first(), interface);
sequence_generator.set("sequence.type", sequence_cpp_type.name);
sequence_generator.set("sequence.storage_type", sequence_storage_type_to_cpp_storage_type_name(sequence_cpp_type.sequence_storage_type));
if (!optional_default_value.has_value()) {
if (sequence_cpp_type.sequence_storage_type == IDL::SequenceStorageType::Vector) {
sequence_generator.append(R"~~~(
sequence_generator.append(R"~~~(
Optional<@sequence.storage_type@<@sequence.type@>> @cpp_name@;
)~~~");
} else {
sequence_generator.append(R"~~~(
Optional<@sequence.storage_type@> @cpp_name@;
)~~~");
}
} else {
if (optional_default_value != "[]")
TODO();
@ -1054,9 +1060,15 @@ static void generate_to_cpp(SourceGenerator& generator, ParameterType& parameter
}
}
sequence_generator.append(R"~~~(
if (optional) {
sequence_generator.append(R"~~~(
if (!@js_name@@js_suffix@.is_undefined()) {
)~~~");
} else {
sequence_generator.append(R"~~~(
if (!@js_name@@js_suffix@.is_nullish()) {
)~~~");
}
}
sequence_generator.append(R"~~~(
@ -1068,9 +1080,9 @@ static void generate_to_cpp(SourceGenerator& generator, ParameterType& parameter
return vm.throw_completion<JS::TypeError>(JS::ErrorType::NotIterable, @js_name@@js_suffix@.to_string_without_side_effects());
)~~~");
parameterized_type.generate_sequence_from_iterable(sequence_generator, ByteString::formatted("{}{}", acceptable_cpp_name, optional ? "_non_optional" : ""), ByteString::formatted("{}{}", js_name, js_suffix), ByteString::formatted("{}{}_iterator_method{}", js_name, js_suffix, recursion_depth), interface, recursion_depth + 1);
parameterized_type.generate_sequence_from_iterable(sequence_generator, ByteString::formatted("{}{}", acceptable_cpp_name, optional || parameter.type->is_nullable() ? "_non_optional" : ""), ByteString::formatted("{}{}", js_name, js_suffix), ByteString::formatted("{}{}_iterator_method{}", js_name, js_suffix, recursion_depth), interface, recursion_depth + 1);
if (optional) {
if (optional || parameter.type->is_nullable()) {
sequence_generator.append(R"~~~(
@cpp_name@ = move(@cpp_name@_non_optional);
}
@ -4060,6 +4072,24 @@ JS_DEFINE_NATIVE_FUNCTION(@class_name@::@attribute.getter_callback@)
}
)~~~");
}
// If a reflected IDL attribute has the type FrozenArray<T>?, where T is either Element or an interface that
// inherits from Element, then with attr being the reflected content attribute name:
// FIXME: Handle "an interface that inherits from Element".
// FIXME: This should handle "FrozenArray" rather than "sequence".
else if (is_nullable_sequence_of_single_type(attribute.type, "Element"sv)) {
// 1. Let elements be the result of running this's get the attr-associated elements.
attribute_generator.append(R"~~~(
static auto content_attribute = "@attribute.reflect_name@"_fly_string;
auto retval = impl->get_the_attribute_associated_elements(content_attribute, TRY(throw_dom_exception_if_needed(vm, [&] { return impl->@attribute.cpp_name@(); })));
)~~~");
// FIXME: 2. If the contents of elements is equal to the contents of this's cached attr-associated elements, then return
// this's cached attr-associated elements object.
// FIXME: 3. Let elementsAsFrozenArray be elements, converted to a FrozenArray<T>?.
// FIXME: 4. Set this's cached attr-associated elements to elements.
// FIXME: 5. Set this's cached attr-associated elements object to elementsAsFrozenArray.
} else {
attribute_generator.append(R"~~~(
auto retval = impl->get_attribute_value("@attribute.reflect_name@"_fly_string);
@ -4202,6 +4232,45 @@ JS_DEFINE_NATIVE_FUNCTION(@class_name@::@attribute.setter_callback@)
// 3. Set this's explicitly set attr-element to a weak reference to the given value.
attribute_generator.append(R"~~~(
TRY(throw_dom_exception_if_needed(vm, [&] { return impl->set_@attribute.cpp_name@(cpp_value); }));
)~~~");
}
// If a reflected IDL attribute has the type FrozenArray<T>?, where T is either Element or an interface
// that inherits from Element, then with attr being the reflected content attribute name:
// FIXME: Handle "an interface that inherits from Element".
// FIXME: This should handle "FrozenArray" rather than "sequence".
else if (is_nullable_sequence_of_single_type(attribute.type, "Element"sv)) {
// 1. If the given value is null:
// 1. Set this's explicitly set attr-elements to null.
// 2. Run this's delete the content attribute.
// 3. Return.
attribute_generator.append(R"~~~(
static auto content_attribute = "@attribute.reflect_name@"_fly_string;
if (!cpp_value.has_value()) {
TRY(throw_dom_exception_if_needed(vm, [&] { return impl->set_@attribute.cpp_name@({}); }));
impl->remove_attribute(content_attribute);
return JS::js_undefined();
}
)~~~");
// 2. Run this's set the content attribute with the empty string.
attribute_generator.append(R"~~~(
MUST(impl->set_attribute(content_attribute, String {}));
)~~~");
// 3. Let elements be an empty list.
// 4. For each element in the given value:
// 1. Append a weak reference to element to elements.
// 5. Set this's explicitly set attr-elements to elements.
attribute_generator.append(R"~~~(
Vector<WeakPtr<DOM::Element>> elements;
elements.ensure_capacity(cpp_value->size());
for (auto const& element : *cpp_value) {
elements.unchecked_append(*element);
}
TRY(throw_dom_exception_if_needed(vm, [&] { return impl->set_@attribute.cpp_name@(move(elements)); }));
)~~~");
} else if (attribute.type->is_nullable()) {
attribute_generator.append(R"~~~(

View file

@ -2,32 +2,32 @@ Harness status: OK
Found 27 tests
16 Pass
11 Fail
22 Pass
5 Fail
Pass aria-activedescendant element reflection
Pass aria-activedescendant If the content attribute is set directly, the IDL attribute getter always returns the first element whose ID matches the content attribute.
Pass aria-activedescendant Setting the IDL attribute to an element which is not the first element in DOM order with its ID causes the content attribute to be an empty string
Pass aria-activedescendant Setting an element reference that crosses into a shadow tree is disallowed, but setting one that is in a shadow inclusive ancestor is allowed.
Fail aria-errormessage
Pass aria-errormessage
Pass ariaErrorMessageElement is not defined
Fail aria-details
Pass aria-details
Pass aria-activedescendant Deleting a reflected element should return null for the IDL attribute and the content attribute will be empty.
Pass aria-activedescendant Changing the ID of an element doesn't lose the reference.
Pass aria-activedescendant Reparenting an element into a descendant shadow scope hides the element reference.
Pass aria-activedescendant Reparenting referenced element cannot cause retargeting of reference.
Pass aria-activedescendant Element reference set in invalid scope remains intact throughout move to valid scope.
Fail aria-labelledby.
Fail aria-controls.
Fail aria-describedby.
Fail aria-flowto.
Fail aria-owns.
Pass aria-controls.
Pass aria-describedby.
Pass aria-flowto.
Pass aria-owns.
Fail shadow DOM behaviour for FrozenArray element reflection.
Fail Moving explicitly set elements across shadow DOM boundaries.
Fail Moving explicitly set elements around within the same scope, and removing from the DOM.
Pass Moving explicitly set elements around within the same scope, and removing from the DOM.
Pass aria-activedescendant Reparenting.
Pass aria-activedescendant Attaching element reference before it's inserted into the DOM.
Pass aria-activedescendant Cross-document references and moves.
Pass aria-activedescendant Adopting element keeps references.
Pass Caching invariant different attributes.
Pass Caching invariant different elements.
Fail Passing values of the wrong type should throw a TypeError
Fail Caching invariant different attributes.
Fail Caching invariant different elements.
Pass Passing values of the wrong type should throw a TypeError