Skip to content
Permalink
Browse files
Make custom elements's default ARIA id-ref work with shadow DOM
https://bugs.webkit.org/show_bug.cgi?id=245467

Reviewed by Manuel Rego Casasnovas.

Make ElementInternals's id-ref ARIA attributes work with shadow DOM by recursively updating
relations between AX objects in AXObjectCache::updateRelationsIfNeeded.

* LayoutTests/accessibility/custom-elements/controls-shadow-expected.txt: Added.
* LayoutTests/accessibility/custom-elements/controls-shadow.html: Added.
* LayoutTests/accessibility/custom-elements/describedby-shadow-expected.txt: Added.
* LayoutTests/accessibility/custom-elements/describedby-shadow.html: Added.
* LayoutTests/accessibility/custom-elements/flowto-shadow-expected.txt: Added.
* LayoutTests/accessibility/custom-elements/flowto-shadow.html: Added.
* LayoutTests/accessibility/custom-elements/menuitem-shadow-expected.txt: Added.
* LayoutTests/accessibility/custom-elements/menuitem-shadow.html: Added.

* Source/WebCore/accessibility/AXObjectCache.cpp:
(WebCore::AXObjectCache::updateRelationsIfNeeded):
(WebCore::AXObjectCache::updateRelationsForTree): Extracted from updateRelationsIfNeeded.
* Source/WebCore/accessibility/AXObjectCache.h:

* Source/WebCore/dom/CustomElementDefaultARIA.cpp:
(WebCore::isElementVisible): Fixed a bug that this function returns false when
"thisElement" is disconnected but element is in the document tree.

Canonical link: https://commits.webkit.org/254762@main
  • Loading branch information
rniwa committed Sep 22, 2022
1 parent 1c25190 commit 8f927ad0d4312332b9c76c0b2219e02da04a202b
Show file tree
Hide file tree
Showing 11 changed files with 304 additions and 3 deletions.
@@ -0,0 +1,16 @@
This tests that ElementInternals.ariaControlsElements can reference nodes outside the shadow tree.

On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".


PASS internals.ariaControlsElements.length is 2
PASS internals.ariaControlsElements[0] is document.querySelectorAll(".control")[0]
PASS internals.ariaControlsElements[1] is document.querySelectorAll(".control")[1]
PASS labelForControl(customTab.ariaControlsElementAtIndex(0)) is "AXValue: Panel 1"
PASS labelForControl(customTab.ariaControlsElementAtIndex(1)) is "AXValue: Panel 2"
PASS successfullyParsed is true

TEST COMPLETE
Panel 1
Panel 2

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<body>
<script src="../../resources/js-test.js"></script>
<script src="../../resources/accessibility-helper.js"></script>
<div class="control">Panel 1</div>
<div class="control">Panel 2</div>
<div id="host"></div>
<script>

class CustomTabElement extends HTMLElement {
constructor()
{
super();
window.internals = this.attachInternals();
internals.role = 'tab';
internals.ariaControlsElements = Array.from(document.querySelectorAll('.control'));
shouldBe('internals.ariaControlsElements.length', '2');
shouldBe('internals.ariaControlsElements[0]', 'document.querySelectorAll(".control")[0]');
shouldBe('internals.ariaControlsElements[1]', 'document.querySelectorAll(".control")[1]');
}
};
customElements.define('custom-tab', CustomTabElement);

const customElement = new CustomTabElement;
customElement.id = 'custom-tab';
const shadowRoot = host.attachShadow({'mode': 'closed'});
shadowRoot.appendChild(customElement);

description("This tests that ElementInternals.ariaControlsElements can reference nodes outside the shadow tree.");
if (!window.accessibilityController)
debug('This test requires accessibilityController');
else {
function labelForControl(control) {
if (accessibilityController.platformName == "mac")
return control.childAtIndex(0).stringValue;
return control.stringValue;
}
window.customTab = accessibilityController.accessibleElementById('custom-tab');
shouldBeEqualToString('labelForControl(customTab.ariaControlsElementAtIndex(0))', 'AXValue: Panel 1');
shouldBeEqualToString('labelForControl(customTab.ariaControlsElementAtIndex(1))', 'AXValue: Panel 2');
}

</script>
</body>
</html>
@@ -0,0 +1,31 @@
This tests that aria fallback roles work correctly.

On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".


PASS internals.ariaDescribedByElements.length is 2
PASS internals.ariaDescribedByElements[0] is document.querySelectorAll(".note")[0]
PASS internals.ariaDescribedByElements[1] is document.querySelectorAll(".note")[1]
PASS internals.ariaDetailsElements.length is 2
PASS internals.ariaDetailsElements[0] is document.querySelectorAll(".details")[0]
PASS internals.ariaDetailsElements[1] is document.querySelectorAll(".details")[1]
PASS internals.ariaErrorMessageElements.length is 2
PASS internals.ariaErrorMessageElements[0] is document.querySelectorAll(".error")[0]
PASS internals.ariaErrorMessageElements[1] is document.querySelectorAll(".error")[1]
PASS accessibilityController.accessibleElementById("custom-1").role is "AXRole: AXCheckBox"
PASS accessibilityController.accessibleElementById("custom-1").helpText is "AXHelp: some description other description"
PASS accessibilityController.accessibleElementById("custom-1").detailsElements().length is 2
PASS accessibilityController.accessibleElementById("custom-1").detailsElements()[0].domIdentifier is "details1"
PASS accessibilityController.accessibleElementById("custom-1").detailsElements()[1].domIdentifier is "details2"
PASS accessibilityController.accessibleElementById("custom-1").errorMessageElements().length is 2
PASS accessibilityController.accessibleElementById("custom-1").errorMessageElements()[0].domIdentifier is "error1"
PASS accessibilityController.accessibleElementById("custom-1").errorMessageElements()[1].domIdentifier is "error2"
PASS successfullyParsed is true

TEST COMPLETE
some description
other description
some details
other details
some error
other error
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<body id="body">
<script src="../../resources/js-test.js"></script>
<script src="../../resources/accessibility-helper.js"></script>
<div id="host"></div>
<div id="some" class="note">some description</div>
<div class="note">other description</div>
<div id="details1" class="details">some details</div>
<div id="details2" class="details">other details</div>
<div id="error1" class="error">some error</div>
<div id="error2" class="error">other error</div>
<script>

class CustomElement extends HTMLElement {
constructor()
{
super();
window.internals = this.attachInternals();
internals.role = 'checkbox';
internals.ariaDescribedByElements = Array.from(document.querySelectorAll('.note'));
internals.ariaDetailsElements = Array.from(document.querySelectorAll('.details'));
internals.ariaErrorMessageElements = Array.from(document.querySelectorAll('.error'));
shouldBe('internals.ariaDescribedByElements.length', '2');
shouldBe('internals.ariaDescribedByElements[0]', 'document.querySelectorAll(".note")[0]');
shouldBe('internals.ariaDescribedByElements[1]', 'document.querySelectorAll(".note")[1]');
shouldBe('internals.ariaDetailsElements.length', '2');
shouldBe('internals.ariaDetailsElements[0]', 'document.querySelectorAll(".details")[0]');
shouldBe('internals.ariaDetailsElements[1]', 'document.querySelectorAll(".details")[1]');
shouldBe('internals.ariaErrorMessageElements.length', '2');
shouldBe('internals.ariaErrorMessageElements[0]', 'document.querySelectorAll(".error")[0]');
shouldBe('internals.ariaErrorMessageElements[1]', 'document.querySelectorAll(".error")[1]');
}
}
customElements.define('custom-element', CustomElement);

const shadowRoot = host.attachShadow({mode: 'closed'});
const customElement = new CustomElement;
customElement.id = 'custom-1';
shadowRoot.appendChild(customElement);

description("This tests that aria fallback roles work correctly.");
if (!window.accessibilityController)
debug('This test requires accessibilityController');
else {
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").role', 'AXRole: AXCheckBox');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").helpText', 'AXHelp: some description other description');
shouldBe('accessibilityController.accessibleElementById("custom-1").detailsElements().length', '2');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").detailsElements()[0].domIdentifier', 'details1');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").detailsElements()[1].domIdentifier', 'details2');
shouldBe('accessibilityController.accessibleElementById("custom-1").errorMessageElements().length', '2');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").errorMessageElements()[0].domIdentifier', 'error1');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").errorMessageElements()[1].domIdentifier', 'error2');
}

</script>
</body>
</html>
@@ -0,0 +1,26 @@
This tests that aria fallback roles work correctly.

On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".


PASS internals.ariaFlowToElements.length is 2
PASS internals.ariaFlowToElements[0] is document.querySelectorAll(".flowto")[0]
PASS internals.ariaFlowToElements[1] is document.querySelectorAll(".flowto")[1]
PASS internals.ariaLabelledByElements.length is 2
PASS internals.ariaLabelledByElements[0] is document.querySelectorAll(".label")[0]
PASS internals.ariaLabelledByElements[1] is document.querySelectorAll(".label")[1]
PASS accessibilityController.accessibleElementById("custom-1").role is "AXRole: AXCheckBox"
PASS platformValueForW3CName(accessibilityController.accessibleElementById("custom-1")) is "label 1 label 2"
PASS accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(0).role is "AXRole: AXButton"
PASS accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(0).title is "AXTitle: FlowTo1"
PASS accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(1).role is "AXRole: AXButton"
PASS accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(1).title is "AXTitle: FlowTo2"
PASS accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(2) is null
PASS successfullyParsed is true

TEST COMPLETE
FlowTo1
FlowTo2
label 1
label 2

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<body id="body">
<script src="../../resources/js-test.js"></script>
<script src="../../resources/accessibility-helper.js"></script>
<div role="button" class="flowto">FlowTo1</div>
<div id="flowto2" role="button" class="flowto">FlowTo2</div>
<div class="label">label 1</div>
<div id="label2" class="label">label 2</div>
<div id="host"></div>
<script>

class CustomElement extends HTMLElement {
constructor()
{
super();
window.internals = this.attachInternals();
internals.role = 'checkbox';
internals.ariaFlowToElements = Array.from(document.querySelectorAll('.flowto'));
internals.ariaLabelledByElements = Array.from(document.querySelectorAll('.label'));
shouldBe('internals.ariaFlowToElements.length', '2');
shouldBe('internals.ariaFlowToElements[0]', 'document.querySelectorAll(".flowto")[0]');
shouldBe('internals.ariaFlowToElements[1]', 'document.querySelectorAll(".flowto")[1]');
shouldBe('internals.ariaLabelledByElements.length', '2');
shouldBe('internals.ariaLabelledByElements[0]', 'document.querySelectorAll(".label")[0]');
shouldBe('internals.ariaLabelledByElements[1]', 'document.querySelectorAll(".label")[1]');
}
};
customElements.define('custom-element', CustomElement);

const shadowRoot = host.attachShadow({'mode': 'closed'});
const customElement = new CustomElement;
customElement.id = 'custom-1';
shadowRoot.appendChild(customElement);

description("This tests that aria fallback roles work correctly.");
if (!window.accessibilityController)
debug('This test requires accessibilityController');
else {
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").role', 'AXRole: AXCheckBox');
shouldBeEqualToString('platformValueForW3CName(accessibilityController.accessibleElementById("custom-1"))', 'label 1 label 2');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(0).role', 'AXRole: AXButton');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(0).title', 'AXTitle: FlowTo1');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(1).role', 'AXRole: AXButton');
shouldBeEqualToString('accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(1).title', 'AXTitle: FlowTo2');
shouldBe('accessibilityController.accessibleElementById("custom-1").ariaFlowToElementAtIndex(2)', 'null');
}

</script>
</body>
</html>
@@ -0,0 +1,14 @@
This tests that aria fallback roles work correctly.

On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".


PASS customMenuInternals.ariaActiveDescendantElement is document.getElementById("item-2")
PASS customMenuInternals.ariaActiveDescendantElement is document.getElementById("item-2")
PASS accessibilityController.accessibleElementById("menu-1").selectedChildrenCount is 1
PASS platformValueForW3CName(accessibilityController.accessibleElementById("menu-1").selectedChildAtIndex(0)) is "item 2"
PASS accessibilityController.accessibleElementById("menu-1").selectedChildAtIndex(0).isSelected is true
PASS successfullyParsed is true

TEST COMPLETE

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<body>
<script src="../../resources/js-test.js"></script>
<script src="../../resources/accessibility-helper.js"></script>
<custom-menu id="menu-1">
<div id="host">
<custom-menuitem id="item-1" aria-label="item 1"></custom-menuitem>
<custom-menuitem id="item-2" aria-label="item 2"></custom-menuitem>
</div>
<script>

class CustomMenuElement extends HTMLElement {
constructor()
{
super();
const internals = this.attachInternals();
internals.role = 'menubar';
internals.ariaActiveDescendantElement = document.getElementById('item-2');
window.customMenuInternals = internals;
shouldBe('customMenuInternals.ariaActiveDescendantElement', 'document.getElementById("item-2")');
}
};
customElements.define('custom-menu', CustomMenuElement);

class CustomMenuItemElement extends HTMLElement {
constructor()
{
super();
const internals = this.attachInternals();
internals.role = 'menuitem';
}
}
customElements.define('custom-menuitem', CustomMenuItemElement);

const shadowRoot = host.attachShadow({mode: 'closed'});
shadowRoot.innerHTML = '<custom-menu id="menu-1"><slot></slot></custom-menu>';

description("This tests that aria fallback roles work correctly.");
if (!window.accessibilityController)
debug('This test requires accessibilityController');
else {
shouldBe('accessibilityController.accessibleElementById("menu-1").selectedChildrenCount', '1');
shouldBeEqualToString('platformValueForW3CName(accessibilityController.accessibleElementById("menu-1").selectedChildAtIndex(0))', 'item 2');
shouldBeTrue('accessibilityController.accessibleElementById("menu-1").selectedChildAtIndex(0).isSelected');
}

</script>
</body>
</html>
@@ -71,6 +71,7 @@
#include "Editing.h"
#include "Editor.h"
#include "ElementIterator.h"
#include "ElementRareData.h"
#include "FocusController.h"
#include "Frame.h"
#include "HTMLAreaElement.h"
@@ -112,6 +113,7 @@
#include "SVGElement.h"
#include "ScriptDisallowedScope.h"
#include "ScrollView.h"
#include "ShadowRoot.h"
#include "TextBoundaries.h"
#include "TextControlInnerElements.h"
#include "TextIterator.h"
@@ -4041,7 +4043,12 @@ void AXObjectCache::updateRelationsIfNeeded()
AXLOG("Updating relations.");
m_relations.clear();
m_relationTargets.clear();
updateRelationsForTree(m_document.rootNode());
}

void AXObjectCache::updateRelationsForTree(ContainerNode& rootNode)
{
ASSERT(!rootNode.parentNode());
struct RelationOrigin {
RefPtr<Element> originElement;
AtomString targetID;
@@ -4055,8 +4062,9 @@ void AXObjectCache::updateRelationsIfNeeded()

Vector<RelationOrigin> origins;
Vector<RelationTarget> targets;
// FIXME: Make this code work with shadow DOM.
for (auto& element : descendantsOfType<Element>(m_document.rootNode())) {
for (auto& element : descendantsOfType<Element>(rootNode)) {
if (RefPtr shadowRoot = element.shadowRoot(); shadowRoot && shadowRoot->mode() != ShadowRootMode::UserAgent)
updateRelationsForTree(*shadowRoot);
// Collect all possible origins, i.e., elements with non-empty relation attributes.
for (const auto& attribute : relationAttributes()) {
auto& idsString = element.attributeWithoutSynchronization(attribute);
@@ -524,6 +524,7 @@ class AXObjectCache {
void addRelation(Element*, Element*, AXRelationType);
void addRelation(AccessibilityObject*, AccessibilityObject*, AXRelationType, AddingSymmetricRelation = AddingSymmetricRelation::No);
void updateRelationsIfNeeded();
void updateRelationsForTree(ContainerNode&);
void relationsNeedUpdate(bool);
HashMap<AXID, AXRelations> relations();
const HashSet<AXID>& relationTargetIDs();
@@ -43,7 +43,7 @@ void CustomElementDefaultARIA::setValueForAttribute(const QualifiedName& name, c

static bool isElementVisible(const Element& element, const Element& thisElement)
{
return !element.isConnected() || thisElement.isDescendantOrShadowDescendantOf(element.rootNode());
return !element.isConnected() || element.isInDocumentTree() || thisElement.isDescendantOrShadowDescendantOf(element.rootNode());
}

const AtomString& CustomElementDefaultARIA::valueForAttribute(const Element& thisElement, const QualifiedName& name) const

0 comments on commit 8f927ad

Please sign in to comment.