Bug 1769586 - P2: Implement a11y support of ARIA element reflection. r=Jamie

Depends on D209767

Differential Revision: https://phabricator.services.mozilla.com/D209768
This commit is contained in:
Eitan Isaacson 2024-11-19 15:02:18 +00:00
parent f82db6902a
commit 79884b85d1
15 changed files with 622 additions and 31 deletions

View File

@ -1451,33 +1451,33 @@ static const AttrCharacteristics gWAIUnivAttrMap[] = {
{nsGkAtoms::aria_checked, ATTR_BYPASSOBJ | ATTR_VALTOKEN }, /* exposes checkable obj attr */
{nsGkAtoms::aria_colcount, ATTR_VALINT },
{nsGkAtoms::aria_colindex, ATTR_VALINT },
{nsGkAtoms::aria_controls, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_controls, ATTR_BYPASSOBJ | ATTR_GLOBAL | ATTR_REFLECT_ELEMENTS },
{nsGkAtoms::aria_current, ATTR_BYPASSOBJ_IF_FALSE | ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_describedby, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_describedby, ATTR_BYPASSOBJ | ATTR_GLOBAL | ATTR_REFLECT_ELEMENTS },
// XXX Ideally, aria-description shouldn't expose a description object
// attribute (i.e. it should have ATTR_BYPASSOBJ). However, until the
// description-from attribute is implemented (bug 1726087), clients such as
// NVDA depend on the description object attribute to work out whether the
// accDescription originated from aria-description.
{nsGkAtoms::aria_description, ATTR_GLOBAL },
{nsGkAtoms::aria_details, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_details, ATTR_BYPASSOBJ | ATTR_GLOBAL | ATTR_REFLECT_ELEMENTS },
{nsGkAtoms::aria_disabled, ATTR_BYPASSOBJ | ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_dropeffect, ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_errormessage, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_errormessage, ATTR_BYPASSOBJ | ATTR_GLOBAL | ATTR_REFLECT_ELEMENTS },
{nsGkAtoms::aria_expanded, ATTR_BYPASSOBJ | ATTR_VALTOKEN },
{nsGkAtoms::aria_flowto, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_flowto, ATTR_BYPASSOBJ | ATTR_GLOBAL | ATTR_REFLECT_ELEMENTS },
{nsGkAtoms::aria_grabbed, ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_haspopup, ATTR_BYPASSOBJ_IF_FALSE | ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_hidden, ATTR_BYPASSOBJ | ATTR_VALTOKEN | ATTR_GLOBAL }, /* handled special way */
{nsGkAtoms::aria_invalid, ATTR_BYPASSOBJ | ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_label, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_labelledby, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_labelledby, ATTR_BYPASSOBJ | ATTR_GLOBAL | ATTR_REFLECT_ELEMENTS },
{nsGkAtoms::aria_level, ATTR_BYPASSOBJ }, /* handled via groupPosition */
{nsGkAtoms::aria_live, ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_modal, ATTR_BYPASSOBJ | ATTR_VALTOKEN | ATTR_GLOBAL },
{nsGkAtoms::aria_multiline, ATTR_BYPASSOBJ | ATTR_VALTOKEN },
{nsGkAtoms::aria_multiselectable, ATTR_BYPASSOBJ | ATTR_VALTOKEN },
{nsGkAtoms::aria_owns, ATTR_BYPASSOBJ | ATTR_GLOBAL },
{nsGkAtoms::aria_owns, ATTR_BYPASSOBJ | ATTR_GLOBAL | ATTR_REFLECT_ELEMENTS },
{nsGkAtoms::aria_orientation, ATTR_VALTOKEN },
{nsGkAtoms::aria_posinset, ATTR_BYPASSOBJ }, /* handled via groupPosition */
{nsGkAtoms::aria_pressed, ATTR_BYPASSOBJ | ATTR_VALTOKEN },
@ -1737,3 +1737,22 @@ bool AttrIterator::ExposeAttr(AccAttributes* aTargetAttrs) const {
}
return false;
}
////////////////////////////////////////////////////////////////////////////////
// AttrWithCharacteristicsIterator class
bool AttrWithCharacteristicsIterator::Next() {
for (mIdx++; mIdx < static_cast<int32_t>(ArrayLength(gWAIUnivAttrMap));
mIdx++) {
if (gWAIUnivAttrMap[mIdx].characteristics & mCharacteristics) {
return true;
}
}
return false;
}
nsStaticAtom* AttrWithCharacteristicsIterator::AttrName() const {
return mIdx >= 0
? const_cast<nsStaticAtom*>(gWAIUnivAttrMap[mIdx].attributeName)
: nullptr;
}

View File

@ -124,6 +124,11 @@ const uint8_t ATTR_GLOBAL = 0x1 << 3;
*/
const uint8_t ATTR_VALINT = 0x1 << 4;
/**
* Indicates that the attribute can have reflected elements.
*/
const uint8_t ATTR_REFLECT_ELEMENTS = 0x1 << 5;
////////////////////////////////////////////////////////////////////////////////
// State map entry
@ -346,6 +351,26 @@ class AttrIterator {
uint8_t mAttrCharacteristics;
};
class AttrWithCharacteristicsIterator {
public:
explicit AttrWithCharacteristicsIterator(uint8_t aCharacteristics)
: mIdx(-1), mCharacteristics(aCharacteristics) {}
bool Next();
nsStaticAtom* AttrName() const;
private:
AttrWithCharacteristicsIterator() = delete;
AttrWithCharacteristicsIterator(const AttrWithCharacteristicsIterator&) =
delete;
AttrWithCharacteristicsIterator& operator=(
const AttrWithCharacteristicsIterator&) = delete;
int32_t mIdx;
uint8_t mCharacteristics;
};
} // namespace aria
} // namespace a11y
} // namespace mozilla

View File

@ -5,12 +5,15 @@
#include "AccIterator.h"
#include "AccGroupInfo.h"
#include "ARIAMap.h"
#include "DocAccessible-inl.h"
#include "LocalAccessible-inl.h"
#include "nsAccUtils.h"
#include "XULTreeAccessible.h"
#include "mozilla/a11y/DocAccessibleParent.h"
#include "mozilla/dom/DocumentOrShadowRoot.h"
#include "mozilla/dom/Element.h"
#include "mozilla/dom/HTMLLabelElement.h"
using namespace mozilla;
@ -245,9 +248,14 @@ LocalAccessible* XULDescriptionIterator::Next() {
IDRefsIterator::IDRefsIterator(DocAccessible* aDoc, nsIContent* aContent,
nsAtom* aIDRefsAttr)
: mContent(aContent), mDoc(aDoc), mCurrIdx(0) {
: mContent(aContent), mDoc(aDoc), mCurrIdx(0), mElemIdx(0) {
if (mContent->IsElement()) {
mContent->AsElement()->GetAttr(aIDRefsAttr, mIDs);
if (mIDs.IsEmpty() &&
(aria::AttrCharacteristicsFor(aIDRefsAttr) & ATTR_REFLECT_ELEMENTS)) {
nsAccUtils::GetARIAElementsAttr(mContent->AsElement(), aIDRefsAttr,
mElements);
}
}
}
@ -275,6 +283,13 @@ nsIContent* IDRefsIterator::NextElem() {
if (refContent) return refContent;
}
while (nsIContent* element = mElements.SafeElementAt(mElemIdx++)) {
if (nsCoreUtils::IsDescendantOfAnyShadowIncludingAncestor(element,
mContent)) {
return element;
}
}
return nullptr;
}

View File

@ -16,6 +16,10 @@
class nsITreeView;
namespace mozilla {
namespace dom {
class Element;
}
namespace a11y {
class DocAccessibleParent;
@ -202,9 +206,10 @@ class XULDescriptionIterator : public AccIterable {
};
/**
* Used to iterate through IDs, elements or accessibles pointed by IDRefs
* attribute. Note, any method used to iterate through IDs, elements, or
* accessibles moves iterator to next position.
* Used to iterate through elements referenced through explicitly set
* attr-elements or IDs listed in a content attribute. Note, any method used to
* iterate through IDs, elements, or accessibles moves iterator to next
* position.
*/
class IDRefsIterator : public AccIterable {
public:
@ -240,6 +245,8 @@ class IDRefsIterator : public AccIterable {
nsIContent* mContent;
DocAccessible* mDoc;
nsAString::index_type mCurrIdx;
nsTArray<dom::Element*> mElements;
uint32_t mElemIdx;
};
/**

View File

@ -568,6 +568,22 @@ const nsAttrValue* nsAccUtils::GetARIAAttr(dom::Element* aElement,
return defaults->GetAttr(aName, kNameSpaceID_None);
}
bool nsAccUtils::GetARIAElementsAttr(dom::Element* aElement, nsAtom* aName,
nsTArray<dom::Element*>& aElements) {
if (aElement->HasAttr(aName)) {
aElement->GetExplicitlySetAttrElements(aName, aElements);
return true;
}
if (auto* element = nsGenericHTMLElement::FromNode(aElement)) {
if (auto* internals = element->GetInternals()) {
return internals->GetAttrElements(aName, aElements);
}
}
return false;
}
bool nsAccUtils::ARIAAttrValueIs(dom::Element* aElement, const nsAtom* aName,
const nsAString& aValue,
nsCaseTreatment aCaseSensitive) {

View File

@ -288,6 +288,8 @@ class nsAccUtils {
nsAString& aResult);
static const nsAttrValue* GetARIAAttr(dom::Element* aElement,
const nsAtom* aName);
static bool GetARIAElementsAttr(dom::Element* aElement, nsAtom* aName,
nsTArray<dom::Element*>& aElements);
static bool ARIAAttrValueIs(dom::Element* aElement, const nsAtom* aName,
const nsAString& aValue,
nsCaseTreatment aCaseSensitive);

View File

@ -7,6 +7,7 @@
#include "LocalAccessible-inl.h"
#include "AccIterator.h"
#include "AccAttributes.h"
#include "ARIAMap.h"
#include "CachedTableAccessible.h"
#include "DocAccessible-inl.h"
#include "EventTree.h"
@ -803,11 +804,10 @@ void DocAccessible::AttributeWillChange(dom::Element* aElement,
// Update dependent IDs cache. Take care of elements that are accessible
// because dependent IDs cache doesn't contain IDs from non accessible
// elements.
if (aModType != dom::MutationEvent_Binding::ADDITION) {
RemoveDependentIDsFor(accessible, aAttribute);
RemoveDependentElementsFor(accessible, aAttribute);
}
// elements. We do this for attribute additions as well because there might
// be an ElementInternals default value.
RemoveDependentIDsFor(accessible, aAttribute);
RemoveDependentElementsFor(accessible, aAttribute);
if (aAttribute == nsGkAtoms::id) {
if (accessible->IsActiveDescendantId()) {
@ -1247,7 +1247,7 @@ void DocAccessible::BindToDocument(LocalAccessible* aAccessible,
nsIContent* content = aAccessible->GetContent();
if (content->IsElement() &&
content->AsElement()->HasAttr(nsGkAtoms::aria_owns)) {
nsAccUtils::HasARIAAttr(content->AsElement(), nsGkAtoms::aria_owns)) {
mNotificationController->ScheduleRelocation(aAccessible);
}
}
@ -1897,6 +1897,28 @@ void DocAccessible::AddDependentElementsFor(LocalAccessible* aRelProvider,
break;
}
}
aria::AttrWithCharacteristicsIterator multipleElementsRelationIter(
ATTR_REFLECT_ELEMENTS);
while (multipleElementsRelationIter.Next()) {
nsStaticAtom* attr = multipleElementsRelationIter.AttrName();
if (aRelAttr && aRelAttr != attr) {
continue;
}
nsTArray<dom::Element*> elements;
nsAccUtils::GetARIAElementsAttr(providerEl, attr, elements);
for (dom::Element* targetEl : elements) {
AttrRelProviders& providers =
mDependentElementsMap.LookupOrInsert(targetEl);
AttrRelProvider* provider = new AttrRelProvider(attr, providerEl);
providers.AppendElement(provider);
}
// If the relation attribute was given, we've already handled it. We don't
// have anything else to check.
if (aRelAttr) {
break;
}
}
}
void DocAccessible::RemoveDependentElementsFor(LocalAccessible* aRelProvider,
@ -1927,6 +1949,34 @@ void DocAccessible::RemoveDependentElementsFor(LocalAccessible* aRelProvider,
break;
}
}
aria::AttrWithCharacteristicsIterator multipleElementsRelationIter(
ATTR_REFLECT_ELEMENTS);
while (multipleElementsRelationIter.Next()) {
nsStaticAtom* attr = multipleElementsRelationIter.AttrName();
if (aRelAttr && aRelAttr != attr) {
continue;
}
nsTArray<dom::Element*> elements;
nsAccUtils::GetARIAElementsAttr(providerEl, attr, elements);
for (dom::Element* targetEl : elements) {
if (auto providers = mDependentElementsMap.Lookup(targetEl)) {
providers.Data().RemoveElementsBy([attr,
providerEl](const auto& provider) {
return provider->mRelAttr == attr && provider->mContent == providerEl;
});
if (providers.Data().IsEmpty()) {
providers.Remove();
}
}
}
// If the relation attribute was given, we've already handled it. We don't
// have anything else to check.
if (aRelAttr) {
break;
}
}
}
bool DocAccessible::UpdateAccessibleOnAttrChange(dom::Element* aElement,

View File

@ -2443,7 +2443,8 @@ Relation LocalAccessible::RelationByType(RelationType aType) const {
case RelationType::DETAILS: {
if (mContent->IsElement() &&
mContent->AsElement()->HasAttr(nsGkAtoms::aria_details)) {
nsAccUtils::HasARIAAttr(mContent->AsElement(),
nsGkAtoms::aria_details)) {
return Relation(
new IDRefsIterator(mDoc, mContent, nsGkAtoms::aria_details));
}

View File

@ -467,3 +467,247 @@ between
},
{ chrome: true, topLevel: true }
);
/**
* Test relation defaults via element internals
*/
addAccessibleTask(
`
<div id="dependant1">label</div>
<custom-checkbox id="host"></custom-checkbox>
<div id="dependant2">label2</div>
<script>
customElements.define("custom-checkbox",
class extends HTMLElement {
constructor() {
super();
this.tabIndex = "0";
this._internals = this.attachInternals();
this._internals.role = "checkbox";
this._internals.ariaChecked = "true";
}
get internals() {
return this._internals;
}
}
);
</script>`,
async function (browser, accDoc) {
let host = findAccessibleChildByID(accDoc, "host");
let dependant1 = findAccessibleChildByID(accDoc, "dependant1");
let dependant2 = findAccessibleChildByID(accDoc, "dependant2");
function invokeSetInternals(reflectionAttrName, targetIds) {
if (targetIds) {
Logger.log(
`Setting internals reflected ${reflectionAttrName} attribute to ${targetIds} for host`
);
} else {
Logger.log(
`Removing internals reflected ${reflectionAttrName} attribute from node with host`
);
}
return invokeContentTask(
browser,
[reflectionAttrName, targetIds],
(contentAttr, contentTargetIds) => {
let internals = content.document.getElementById("host").internals;
if (contentTargetIds) {
internals[contentAttr] = contentTargetIds.map(targetId =>
content.document.getElementById(targetId)
);
} else {
internals[contentAttr] = null;
}
}
);
}
async function testInternalsRelation(
attrName,
reflectionAttrName,
hostRelation,
dependantRelation
) {
info(`setting default ${reflectionAttrName}`);
await invokeSetInternals(reflectionAttrName, ["dependant1"]);
await testCachedRelation(host, hostRelation, [dependant1]);
await testCachedRelation(dependant1, dependantRelation, [host]);
await testCachedRelation(dependant2, dependantRelation, null);
info(`setting override ${attrName}`);
await invokeSetAttribute(browser, "host", attrName, "dependant2");
await testCachedRelation(host, hostRelation, [dependant2]);
await testCachedRelation(dependant2, dependantRelation, [host]);
await testCachedRelation(dependant1, dependantRelation, null);
info(`unsetting default ${reflectionAttrName} and ${attrName} override`);
await invokeSetInternals(reflectionAttrName, null);
await invokeSetAttribute(browser, "host", attrName, null);
await testCachedRelation(host, hostRelation, null);
await testCachedRelation(dependant2, dependantRelation, null);
await testCachedRelation(dependant1, dependantRelation, null);
}
await testInternalsRelation(
"aria-labelledby",
"ariaLabelledByElements",
RELATION_LABELLED_BY,
RELATION_LABEL_FOR
);
await testInternalsRelation(
"aria-describedby",
"ariaDescribedByElements",
RELATION_DESCRIBED_BY,
RELATION_DESCRIPTION_FOR
);
await testInternalsRelation(
"aria-controls",
"ariaControlsElements",
RELATION_CONTROLLER_FOR,
RELATION_CONTROLLED_BY
);
await testInternalsRelation(
"aria-flowto",
"ariaFlowToElements",
RELATION_FLOWS_TO,
RELATION_FLOWS_FROM
);
await testInternalsRelation(
"aria-details",
"ariaDetailsElements",
RELATION_DETAILS,
RELATION_DETAILS_FOR
);
await testInternalsRelation(
"aria-errormessage",
"ariaErrorMessageElements",
RELATION_ERRORMSG,
RELATION_ERRORMSG_FOR
);
}
);
/**
* Moving explicitly set elements across shadow DOM boundaries.
*/
addAccessibleTask(
`
<div id="describedButtonContainer">
<div id="buttonDescription1">Delicious</div>
<div id="buttonDescription2">Nutritious</div>
<div id="outerShadowHost"></div>
<button id="describedElement">Button</button>
</div>
<script>
const buttonDescription1 = document.getElementById("buttonDescription1");
const buttonDescription2 = document.getElementById("buttonDescription2");
const outerShadowRoot = outerShadowHost.attachShadow({mode: "open"});
const innerShadowHost = document.createElement("div");
outerShadowRoot.appendChild(innerShadowHost);
const innerShadowRoot = innerShadowHost.attachShadow({mode: "open"});
const describedElement = document.getElementById("describedElement");
// Add some attr associated light DOM elements.
describedElement.ariaDescribedByElements = [buttonDescription1, buttonDescription2];
</script>`,
async function (browser, accDoc) {
const waitAndReturnRecreated = acc => {
const id = getAccessibleDOMNodeID(acc);
return waitForEvents([
[EVENT_HIDE, acc],
[EVENT_SHOW, id],
]).then(evts => evts[1].accessible);
};
let describedAcc = findAccessibleChildByID(accDoc, "describedElement");
let accDescription1 = findAccessibleChildByID(accDoc, "buttonDescription1");
let accDescription2 = findAccessibleChildByID(accDoc, "buttonDescription2");
// All elements were in the same scope, so relations are intact.
await testCachedRelation(describedAcc, RELATION_DESCRIBED_BY, [
accDescription1,
accDescription2,
]);
await testCachedRelation(accDescription1, RELATION_DESCRIPTION_FOR, [
describedAcc,
]);
await testCachedRelation(accDescription2, RELATION_DESCRIPTION_FOR, [
describedAcc,
]);
let onRecreated = waitAndReturnRecreated(describedAcc);
await invokeContentTask(browser, [], () => {
const outerShadowRoot =
content.document.getElementById("outerShadowHost").shadowRoot;
const describedElement =
content.document.getElementById("describedElement");
outerShadowRoot.appendChild(describedElement);
});
info("Waiting for described accessible to be recreated");
describedAcc = await onRecreated;
// Relations should still be intact, we are referencing elements in a lighter scope.
await testCachedRelation(describedAcc, RELATION_DESCRIBED_BY, [
accDescription1,
accDescription2,
]);
await testCachedRelation(accDescription1, RELATION_DESCRIPTION_FOR, [
describedAcc,
]);
await testCachedRelation(accDescription2, RELATION_DESCRIPTION_FOR, [
describedAcc,
]);
// Move the explicitly set elements into a deeper shadow DOM.
onRecreated = Promise.all([
waitAndReturnRecreated(accDescription1),
waitAndReturnRecreated(accDescription2),
]);
await invokeContentTask(browser, [], () => {
const buttonDescription1 =
content.document.getElementById("buttonDescription1");
const buttonDescription2 =
content.document.getElementById("buttonDescription2");
const innerShadowRoot =
content.document.getElementById("outerShadowHost").shadowRoot
.firstElementChild.shadowRoot;
innerShadowRoot.appendChild(buttonDescription1);
innerShadowRoot.appendChild(buttonDescription2);
});
[accDescription1, accDescription2] = await onRecreated;
// Relation is severed, because relation dependants are no longer in a valid scope.
await testCachedRelation(describedAcc, RELATION_DESCRIBED_BY, []);
await testCachedRelation(accDescription1, RELATION_DESCRIPTION_FOR, []);
await testCachedRelation(accDescription2, RELATION_DESCRIPTION_FOR, []);
// Move into the same shadow scope as the explicitly set elements.
onRecreated = waitAndReturnRecreated(describedAcc);
await invokeContentTask(browser, [], () => {
const outerShadowRoot =
content.document.getElementById("outerShadowHost").shadowRoot;
const describedElement =
outerShadowRoot.getElementById("describedElement");
const innerShadowRoot = outerShadowRoot.firstElementChild.shadowRoot;
innerShadowRoot.appendChild(describedElement);
});
describedAcc = await onRecreated;
// Relation is restored, because target is now in same shadow scope.
await testCachedRelation(describedAcc, RELATION_DESCRIBED_BY, [
accDescription1,
accDescription2,
]);
await testCachedRelation(accDescription1, RELATION_DESCRIPTION_FOR, [
describedAcc,
]);
await testCachedRelation(accDescription2, RELATION_DESCRIPTION_FOR, [
describedAcc,
]);
}
);

View File

@ -7,7 +7,35 @@
/* import-globals-from ../../mochitest/role.js */
loadScripts({ name: "role.js", dir: MOCHITESTS_DIR });
async function testContainer1(browser, accDoc) {
requestLongerTimeout(2);
function invokeSetAriaOwns(
browser,
id,
children = null,
elementReflection = false
) {
if (!elementReflection) {
return invokeSetAttribute(browser, id, "aria-owns", children);
}
return invokeContentTask(
browser,
[id, children],
(contentId, contentChildrenIds) => {
let elm = content.document.getElementById(contentId);
if (contentChildrenIds) {
elm.ariaOwnsElements = contentChildrenIds
.split(" ")
.map(childId => content.document.getElementById(childId));
} else {
elm.ariaOwnsElements = null;
}
}
);
}
async function testContainer1(browser, accDoc, elementReflection = false) {
const id = "t1_container";
const docID = getAccessibleDOMNodeID(accDoc);
const acc = findAccessibleChildByID(accDoc, id);
@ -21,7 +49,12 @@ async function testContainer1(browser, accDoc) {
/* ================ Change ARIA owns ====================================== */
let onReorder = waitForEvent(EVENT_REORDER, id);
await invokeSetAttribute(browser, id, "aria-owns", "t1_button t1_subdiv");
await invokeSetAriaOwns(
browser,
id,
"t1_button t1_subdiv",
elementReflection
);
await onReorder;
// children are swapped again, button and subdiv are appended to
@ -37,7 +70,7 @@ async function testContainer1(browser, accDoc) {
/* ================ Remove ARIA owns ====================================== */
onReorder = waitForEvent(EVENT_REORDER, id);
await invokeSetAttribute(browser, id, "aria-owns");
await invokeSetAriaOwns(browser, id, null, elementReflection);
await onReorder;
// children follow the DOM order
@ -48,7 +81,12 @@ async function testContainer1(browser, accDoc) {
/* ================ Set ARIA owns ========================================= */
onReorder = waitForEvent(EVENT_REORDER, id);
await invokeSetAttribute(browser, id, "aria-owns", "t1_button t1_subdiv");
await invokeSetAriaOwns(
browser,
id,
"t1_button t1_subdiv",
elementReflection
);
await onReorder;
// children are swapped again, button and subdiv are appended to
@ -180,7 +218,7 @@ async function removeContainer(browser, accDoc) {
testAccessibleTree(acc, tree);
}
async function stealAndRecacheChildren(browser, accDoc) {
async function stealAndRecacheChildren(browser, accDoc, elementReflection) {
const id1 = "t3_container1";
const id2 = "t3_container2";
const acc1 = findAccessibleChildByID(accDoc, id1);
@ -188,7 +226,7 @@ async function stealAndRecacheChildren(browser, accDoc) {
/* ================ Attempt to steal from other ARIA owns ================= */
let onReorder = waitForEvent(EVENT_REORDER, id2);
await invokeSetAttribute(browser, id2, "aria-owns", "t3_child");
await invokeSetAriaOwns(browser, id2, "t3_child", elementReflection);
await invokeContentTask(browser, [id2], id => {
let div = content.document.createElement("div");
div.setAttribute("role", "radio");
@ -228,7 +266,7 @@ async function showHiddenElement(browser, accDoc) {
testAccessibleTree(acc, tree);
}
async function rearrangeARIAOwns(browser, accDoc) {
async function rearrangeARIAOwns(browser, accDoc, elementReflection) {
const id = "t5_container";
const acc = findAccessibleChildByID(accDoc, id);
const tests = [
@ -244,7 +282,7 @@ async function rearrangeARIAOwns(browser, accDoc) {
for (let { val, roleList } of tests) {
let onReorder = waitForEvent(EVENT_REORDER, id);
await invokeSetAttribute(browser, id, "aria-owns", val);
await invokeSetAriaOwns(browser, id, val, elementReflection);
await onReorder;
let tree = { SECTION: [] };
@ -293,6 +331,19 @@ addAccessibleTask(
{ iframe: true, remoteIframe: true }
);
addAccessibleTask(
"e10s/doc_treeupdate_ariaowns.html",
async function (browser, accDoc) {
await testContainer1(browser, accDoc, true);
await removeContainer(browser, accDoc);
await stealAndRecacheChildren(browser, accDoc, true);
await showHiddenElement(browser, accDoc);
await rearrangeARIAOwns(browser, accDoc, true);
await removeNotARIAOwnedEl(browser, accDoc);
},
{ iframe: true, remoteIframe: true }
);
// Test owning an ancestor which isn't created yet with an iframe in the
// subtree.
addAccessibleTask(
@ -455,3 +506,41 @@ addAccessibleTask(
},
{ chrome: false, iframe: true, remoteIframe: true }
);
/**
* Test relation defaults via element internals
*/
addAccessibleTask(
`
<div role="listbox">
<div role="listitem" id="l1"></div>
<div role="listitem" id="l2"></div>
<div role="listitem" id="l3"></div>
</div>
<custom-listbox id="listbox"></custom-listbox>
<div role="listbox">
<div role="listitem" id="l4"></div>
</div>
<script>
customElements.define("custom-listbox",
class extends HTMLElement {
constructor() {
super();
this.tabIndex = "0"
this._internals = this.attachInternals();
this._internals.role = "listbox";
this._internals.ariaOwnsElements = Array.from(this.previousElementSibling.children)
}
}
);
</script>`,
async function (browser, accDoc) {
let listbox = findAccessibleChildByID(accDoc, "listbox");
is(listbox.children.length, 3, "got children");
let onReorder = waitForEvent(EVENT_REORDER, "listbox");
invokeSetAriaOwns(browser, "listbox", "l4");
await onReorder;
}
);

View File

@ -134,6 +134,50 @@ async function testCachedRelation(identifier, relType, relatedIdentifiers) {
}, "No unexpected targets found.");
}
/**
* Asynchronously set or remove content element's reflected elements attribute
* (in content process if e10s is enabled).
* @param {Object} browser current "tabbrowser" element
* @param {String} id content element id
* @param {String} attr attribute name
* @param {String?} value optional attribute value, if not present, remove
* attribute
* @return {Promise} promise indicating that attribute is set/removed
*/
function invokeSetReflectedElementsAttribute(browser, id, attr, targetIds) {
if (targetIds) {
Logger.log(
`Setting reflected ${attr} attribute to ${targetIds} for node with id: ${id}`
);
} else {
Logger.log(`Removing reflected ${attr} attribute from node with id: ${id}`);
}
return invokeContentTask(
browser,
[id, attr, targetIds],
(contentId, contentAttr, contentTargetIds) => {
let elm = content.document.getElementById(contentId);
if (contentTargetIds) {
elm[contentAttr] = contentTargetIds.map(targetId =>
content.document.getElementById(targetId)
);
} else {
elm[contentAttr] = null;
}
}
);
}
const REFLECTEDATTR_NAME_MAP = {
"aria-controls": "ariaControlsElements",
"aria-describedby": "ariaDescribedByElements",
"aria-details": "ariaDetailsElements",
"aria-errormessage": "ariaErrorMessageElements",
"aria-flowto": "ariaFlowToElements",
"aria-labelledby": "ariaLabelledByElements",
};
async function testRelated(
browser,
accDoc,
@ -148,13 +192,14 @@ async function testRelated(
/**
* Test data has the format of:
* {
* desc {String} description for better logging
* attrs {?Array} an optional list of attributes to update
* expected {Array} expected relation values for dependant1, dependant2
* desc {String} description for better logging
* attrs {?Array} an optional list of attributes to update
* reflectedattr {?Array} an optional list of reflected attributes to update
* expected {Array} expected relation values for dependant1, dependant2
* and host respectively.
* }
*/
const tests = [
let tests = [
{
desc: "No attribute",
expected: [null, null, null],
@ -181,13 +226,45 @@ async function testRelated(
},
];
for (let { desc, attrs, expected } of tests) {
let reflectedAttrName = REFLECTEDATTR_NAME_MAP[attr];
if (reflectedAttrName) {
tests = tests.concat([
{
desc: "Set reflected attribute",
reflectedattr: [{ key: reflectedAttrName, value: ["dependant1"] }],
expected: [host, null, dependant1],
},
{
desc: "Change reflected attribute",
reflectedattr: [{ key: reflectedAttrName, value: ["dependant2"] }],
expected: [null, host, dependant2],
},
{
desc: "Change reflected attribute to multiple targets",
reflectedattr: [
{ key: reflectedAttrName, value: ["dependant2", "dependant1"] },
],
expected: [host, host, [dependant1, dependant2]],
},
{
desc: "Remove reflected attribute",
reflectedattr: [{ key: reflectedAttrName, value: null }],
expected: [null, null, null],
},
]);
}
for (let { desc, attrs, reflectedattr, expected } of tests) {
info(desc);
if (attrs) {
for (let { key, value } of attrs) {
await invokeSetAttribute(browser, "host", key, value);
}
} else if (reflectedattr) {
for (let { key, value } of reflectedattr) {
await invokeSetReflectedElementsAttribute(browser, "host", key, value);
}
}
await testCachedRelation(dependant1, dependantRelation, expected[0]);

View File

@ -1997,6 +1997,22 @@ Element* Element::GetExplicitlySetAttrElement(nsAtom* aAttr) const {
return nullptr;
}
void Element::GetExplicitlySetAttrElements(
nsAtom* aAttr, nsTArray<Element*>& aElements) const {
if (const nsExtendedDOMSlots* slots = GetExistingExtendedDOMSlots()) {
if (auto attrElementsMaybeEntry = slots->mAttrElementsMap.Lookup(aAttr)) {
auto& [attrElements, cachedAttrElements] = attrElementsMaybeEntry.Data();
if (attrElements) {
for (const nsWeakPtr& weakEl : *attrElements) {
if (nsCOMPtr<Element> attrEl = do_QueryReferent(weakEl)) {
aElements.AppendElement(attrEl);
}
}
}
}
}
}
void Element::GetElementsWithGrid(nsTArray<RefPtr<Element>>& aElements) {
nsINode* cur = this;
while (cur) {

View File

@ -1339,6 +1339,16 @@ class Element : public FragmentOrElement {
*/
Element* GetExplicitlySetAttrElement(nsAtom* aAttr) const;
/**
* Gets the attribute elements for the given attribute. Unlike
* GetAttrAssociatedElements, this returns an uncached array of explicitly set
* elements without checking if they are a descendant of any of this element's
* shadow-including ancestors. It also does not attempt to retrieve elements
* using the ids set in the content attribute.
*/
void GetExplicitlySetAttrElements(nsAtom* aAttr,
nsTArray<Element*>& aElements) const;
PseudoStyleType GetPseudoElementType() const {
nsresult rv = NS_OK;
auto raw = GetProperty(nsGkAtoms::pseudoProperty, &rv);

View File

@ -655,4 +655,22 @@ void ElementInternals::GetAttrElements(
cachedAttrElements = std::move(elements);
}
bool ElementInternals::GetAttrElements(nsAtom* aAttr,
nsTArray<Element*>& aElements) {
aElements.Clear();
auto attrElementsMaybeEntry = mAttrElementsMap.Lookup(aAttr);
if (!attrElementsMaybeEntry) {
return false;
}
auto& [attrElements, cachedAttrElements] = attrElementsMaybeEntry.Data();
for (const nsWeakPtr& weakEl : attrElements) {
if (nsCOMPtr<Element> attrEl = do_QueryReferent(weakEl)) {
aElements.AppendElement(attrEl);
}
}
return true;
}
} // namespace mozilla::dom

View File

@ -193,6 +193,8 @@ class ElementInternals final : public nsIFormControl,
nsresult SetAttr(nsAtom* aName, const nsAString& aValue);
bool GetAttrElements(nsAtom* aAttr, nsTArray<Element*>& aElements);
const AttrArray& GetAttrs() const { return mAttrs; }
DocGroup* GetDocGroup();