Bug 1879255 part 2: Add a dependent elements map to DocAccessible and use it for popoverTargetElement. r=morgan

As well as getting an invoker's popover target, we need to be able to do the reverse: get a popover's invokers.
We can already do this when the popovertarget content attribute is set to a string id using the dependent ids map.
However, the popover target can also be explicitly set to a DOM element using the .popoverTargetElement WebIDL attribute.
For this, we need a new map which maps from target elements instead of target ids.
RelatedAccIterator has also been updated to use this map.
DocAccessible::QueueCacheUpdateForDependentRelations had to be updated as well.
Rather than duplicating logic, RelatedAccIterator has been taught how to optionally return all relations and QueueCacheUpdateForDependentRelations now uses RelatedAccIterator.

Differential Revision: https://phabricator.services.mozilla.com/D201661
This commit is contained in:
James Teh 2024-02-21 00:05:21 +00:00
parent 33de5c2029
commit df9bf2b1df
8 changed files with 376 additions and 37 deletions

View file

@ -71,7 +71,12 @@ AccIterator::IteratorState::IteratorState(const LocalAccessible* aParent,
RelatedAccIterator::RelatedAccIterator(DocAccessible* aDocument,
nsIContent* aDependentContent,
nsAtom* aRelAttr)
: mDocument(aDocument), mRelAttr(aRelAttr), mProviders(nullptr), mIndex(0) {
: mDocument(aDocument),
mDependentContent(aDependentContent),
mRelAttr(aRelAttr),
mProviders(nullptr),
mIndex(0),
mIsWalkingDependentElements(false) {
nsAutoString id;
if (aDependentContent->IsElement() &&
aDependentContent->AsElement()->GetAttr(nsGkAtoms::id, id)) {
@ -80,26 +85,57 @@ RelatedAccIterator::RelatedAccIterator(DocAccessible* aDocument,
}
LocalAccessible* RelatedAccIterator::Next() {
if (!mProviders) return nullptr;
if (!mProviders || mIndex == mProviders->Length()) {
if (mIsWalkingDependentElements) {
// We've walked both dependent ids and dependent elements, so there are
// no more targets.
return nullptr;
}
// We've returned all dependent ids, but there might be dependent elements
// too. Walk those next.
mIsWalkingDependentElements = true;
mIndex = 0;
if (auto providers =
mDocument->mDependentElementsMap.Lookup(mDependentContent)) {
mProviders = &providers.Data();
} else {
mProviders = nullptr;
return nullptr;
}
}
while (mIndex < mProviders->Length()) {
const auto& provider = (*mProviders)[mIndex++];
// Return related accessible for the given attribute.
if (provider->mRelAttr == mRelAttr) {
LocalAccessible* related = mDocument->GetAccessible(provider->mContent);
if (related) {
return related;
}
if (mRelAttr && provider->mRelAttr != mRelAttr) {
continue;
}
// If we're walking elements (not ids), the explicitly set attr-element
// `mDependentContent` must be a descendant of any of the refering element
// `mProvider->mContent`'s shadow-including ancestors.
if (mIsWalkingDependentElements &&
!nsCoreUtils::IsDescendantOfAnyShadowIncludingAncestor(
mDependentContent, provider->mContent)) {
continue;
}
LocalAccessible* related = mDocument->GetAccessible(provider->mContent);
if (related) {
return related;
}
// If the document content is pointed by relation then return the
// document itself.
if (provider->mContent == mDocument->GetContent()) {
return mDocument;
}
// If the document content is pointed by relation then return the
// document itself.
if (provider->mContent == mDocument->GetContent()) {
return mDocument;
}
}
// We exhausted mProviders without returning anything.
if (!mIsWalkingDependentElements) {
// Call this function again to start walking the dependent elements.
return Next();
}
return nullptr;
}

View file

@ -67,7 +67,9 @@ class AccIterator : public AccIterable {
/**
* Allows to traverse through related accessibles that are pointing to the given
* dependent accessible by relation attribute.
* dependent accessible by relation attribute. This is typically used to query
* implicit reverse relations; e.g. calculating the LABEL_FOR relation for a
* label where that label was referenced using aria-labelledby.
*/
class RelatedAccIterator : public AccIterable {
public:
@ -79,7 +81,7 @@ class RelatedAccIterator : public AccIterable {
* @param aDependentContent [in] the content of dependent accessible that
* relations were requested for
* @param aRelAttr [in] relation attribute that relations are
* pointed by
* pointed by, null for all relations
*/
RelatedAccIterator(DocAccessible* aDocument, nsIContent* aDependentContent,
nsAtom* aRelAttr);
@ -97,9 +99,11 @@ class RelatedAccIterator : public AccIterable {
RelatedAccIterator& operator=(const RelatedAccIterator&);
DocAccessible* mDocument;
nsIContent* mDependentContent;
nsAtom* mRelAttr;
DocAccessible::AttrRelProviders* mProviders;
uint32_t mIndex;
bool mIsWalkingDependentElements;
};
/**

View file

@ -620,3 +620,20 @@ bool nsCoreUtils::IsDocumentVisibleConsideringInProcessAncestors(
} while ((parent = parent->GetInProcessParentDocument()));
return true;
}
bool nsCoreUtils::IsDescendantOfAnyShadowIncludingAncestor(
nsINode* aDescendant, nsINode* aStartAncestor) {
const nsINode* descRoot = aDescendant->SubtreeRoot();
nsINode* ancRoot = aStartAncestor->SubtreeRoot();
for (;;) {
if (ancRoot == descRoot) {
return true;
}
auto* shadow = mozilla::dom::ShadowRoot::FromNode(ancRoot);
if (!shadow || !shadow->GetHost()) {
break;
}
ancRoot = shadow->GetHost()->SubtreeRoot();
}
return false;
}

View file

@ -324,6 +324,13 @@ class nsCoreUtils {
*/
static bool IsDocumentVisibleConsideringInProcessAncestors(
const Document* aDocument);
/**
* Return true if `aDescendant` is a descendant of any of `aStartAncestor`'s
* shadow-including ancestors.
*/
static bool IsDescendantOfAnyShadowIncludingAncestor(nsINode* aDescendant,
nsINode* aStartAncestor);
};
#endif

View file

@ -67,6 +67,9 @@ static nsStaticAtom* const kRelationAttrs[] = {
static const uint32_t kRelationAttrsLen = ArrayLength(kRelationAttrs);
static nsStaticAtom* const kSingleElementRelationIdlAttrs[] = {
nsGkAtoms::popovertarget};
////////////////////////////////////////////////////////////////////////////////
// Constructor/desctructor
@ -383,25 +386,25 @@ void DocAccessible::QueueCacheUpdate(LocalAccessible* aAcc,
void DocAccessible::QueueCacheUpdateForDependentRelations(
LocalAccessible* aAcc) {
if (!mIPCDoc || !aAcc || !aAcc->Elm() || !aAcc->IsInDocument() ||
aAcc->IsDefunct()) {
if (!mIPCDoc || !aAcc || !aAcc->IsInDocument() || aAcc->IsDefunct()) {
return;
}
nsAutoString ID;
aAcc->DOMNodeID(ID);
if (AttrRelProviders* list = GetRelProviders(aAcc->Elm(), ID)) {
// We call this function when we've noticed an ID change, or when an acc
// is getting bound to its document. We need to ensure any existing accs
// that depend on this acc's ID have their rel cache entries updated.
for (const auto& provider : *list) {
LocalAccessible* relatedAcc = GetAccessible(provider->mContent);
if (!relatedAcc || relatedAcc->IsDefunct() ||
!relatedAcc->IsInDocument() ||
mInsertedAccessibles.Contains(relatedAcc)) {
continue;
}
QueueCacheUpdate(relatedAcc, CacheDomain::Relations);
dom::Element* el = aAcc->Elm();
if (!el) {
return;
}
// We call this function when we've noticed an ID change, or when an acc
// is getting bound to its document. We need to ensure any existing accs
// that depend on this acc's ID or Element have their relation cache entries
// updated.
RelatedAccIterator iter(this, el, nullptr);
while (LocalAccessible* relatedAcc = iter.Next()) {
if (relatedAcc->IsDefunct() || !relatedAcc->IsInDocument() ||
mInsertedAccessibles.Contains(relatedAcc)) {
continue;
}
QueueCacheUpdate(relatedAcc, CacheDomain::Relations);
}
}
@ -507,6 +510,7 @@ void DocAccessible::Shutdown() {
}
mDependentIDsHashes.Clear();
mDependentElementsMap.Clear();
mNodeToAccessibleMap.Clear();
mAnchorJumpElm = nullptr;
@ -1150,6 +1154,7 @@ void DocAccessible::BindToDocument(LocalAccessible* aAccessible,
if (aAccessible->HasOwnContent()) {
AddDependentIDsFor(aAccessible);
AddDependentElementsFor(aAccessible);
nsIContent* content = aAccessible->GetContent();
if (content->IsElement() &&
@ -1769,6 +1774,61 @@ void DocAccessible::RemoveDependentIDsFor(LocalAccessible* aRelProvider,
}
}
void DocAccessible::AddDependentElementsFor(LocalAccessible* aRelProvider,
nsAtom* aRelAttr) {
dom::Element* providerEl = aRelProvider->Elm();
if (!providerEl) {
return;
}
for (nsStaticAtom* attr : kSingleElementRelationIdlAttrs) {
if (aRelAttr && aRelAttr != attr) {
continue;
}
if (dom::Element* targetEl =
providerEl->GetExplicitlySetAttrElement(attr)) {
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,
nsAtom* aRelAttr) {
dom::Element* providerEl = aRelProvider->Elm();
if (!providerEl) {
return;
}
for (nsStaticAtom* attr : kSingleElementRelationIdlAttrs) {
if (aRelAttr && aRelAttr != attr) {
continue;
}
if (dom::Element* targetEl =
providerEl->GetExplicitlySetAttrElement(attr)) {
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,
nsAtom* aAttribute) {
if (aAttribute == nsGkAtoms::role) {
@ -2003,7 +2063,7 @@ bool InsertIterator::Next() {
return false;
}
void DocAccessible::MaybeFireEventsForChangedPopover(LocalAccessible *aAcc) {
void DocAccessible::MaybeFireEventsForChangedPopover(LocalAccessible* aAcc) {
dom::Element* el = aAcc->Elm();
if (!el || !el->IsHTMLElement() || !el->HasAttr(nsGkAtoms::popover)) {
return; // Not a popover.
@ -2620,6 +2680,7 @@ void DocAccessible::UncacheChildrenInSubtree(LocalAccessible* aRoot) {
MaybeFireEventsForChangedPopover(aRoot);
aRoot->mStateFlags |= eIsNotInDocument;
RemoveDependentIDsFor(aRoot);
RemoveDependentElementsFor(aRoot);
// The parent of the removed subtree is about to be cleared, so we must do
// this here rather than in LocalAccessible::UnbindFromParent because we need

View file

@ -121,7 +121,7 @@ class DocAccessible : public HyperTextAccessible,
void QueueCacheUpdate(LocalAccessible* aAcc, uint64_t aNewDomain);
/**
* Walks the mDependentIDsHashes list for the given accessible and
* Walks the dependent ids and elements maps for the given accessible and
* queues a CacheDomain::Relations cache update fore each related acc.
* We call this when we observe an ID mutation or when an acc is bound
* to its document.
@ -485,6 +485,35 @@ class DocAccessible : public HyperTextAccessible,
void RemoveDependentIDsFor(LocalAccessible* aRelProvider,
nsAtom* aRelAttr = nullptr);
/**
* Add dependent elements targeted by a relation attribute on an accessible
* element to the dependent elements cache. This is used for reflected IDL
* attributes which return DOM elements and reflect a content attribute, where
* the IDL attribute has been set to an element. For example, if the
* .popoverTargetElement IDL attribute is set to an element using JS, the
* target element will be added to the dependent elements cache. If the
* relation attribute is not specified, then all relation attributes are
* checked.
*
* @param aRelProvider [in] the accessible with the relation IDL attribute.
* @param aRelAttr [in, optional] the name of the reflected content attribute.
* For example, for the popoverTargetElement IDL attribute, this would be
* "popovertarget".
*/
void AddDependentElementsFor(LocalAccessible* aRelProvider,
nsAtom* aRelAttr = nullptr);
/**
* Remove dependent elements targeted by a relation attribute on an accessible
* element from the dependent elements cache. If the relation attribute is
* not specified, then all relation attributes are checked.
*
* @param aRelProvider [in] the accessible with the relation IDL attribute.
* @param aRelAttr [in, optional] the name of the reflected content attribute.
*/
void RemoveDependentElementsFor(LocalAccessible* aRelProvider,
nsAtom* aRelAttr = nullptr);
/**
* Update or recreate an accessible depending on a changed attribute.
*
@ -727,12 +756,35 @@ class DocAccessible : public HyperTextAccessible,
void RemoveRelProvidersIfEmpty(dom::Element* aElement, const nsAString& aID);
/**
* The cache of IDs pointed by relation attributes.
* A map used to look up the target node for an implicit reverse relation
* where the target of the explicit relation is specified as an id.
* For example:
* <div id="label">Name:</div><input aria-labelledby="label">
* The div should get a LABEL_FOR relation targeting the input. To facilitate
* that, mDependentIDsHashes maps from "label" to an AttrRelProvider
* specifying aria-labelledby and the input. Because ids are scoped to the
* nearest ancestor document or shadow root, mDependentIDsHashes maps from the
* DocumentOrShadowRoot first.
*/
nsClassHashtable<nsPtrHashKey<dom::DocumentOrShadowRoot>,
DependentIDsHashtable>
mDependentIDsHashes;
/**
* A map used to look up the target element for an implicit reverse relation
* where the target of the explicit relation is also specified as an element.
* This is similar to mDependentIDsHashes, except that this is used when a
* DOM property is used to set the relation target element directly, rather
* than using an id. For example:
* <button>More info</button><div popover>Some info</div>
* The button's .popoverTargetElement property is set to the div so that the
* button invokes the popover.
* To facilitate finding the invoker given the popover, mDependentElementsMap
* maps from the div to an AttrRelProvider specifying popovertarget and the
* button.
*/
nsTHashMap<nsIContent*, AttrRelProviders> mDependentElementsMap;
friend class RelatedAccIterator;
/**

View file

@ -293,7 +293,7 @@ addAccessibleTask(
);
/**
* Test details relations on popovers and their invokers.
* Test details relations for the popovertarget content attribute.
*/
addAccessibleTask(
`
@ -304,7 +304,7 @@ addAccessibleTask(
<div id="popover" popover>popover</div>
<div id="details">details</div>
`,
async function testPopover(browser, docAcc) {
async function testPopoverContent(browser, docAcc) {
// The popover is hidden, so nothing should be referring to it.
const hide = findAccessibleChildByID(docAcc, "hide");
await testCachedRelation(hide, RELATION_DETAILS, []);
@ -330,7 +330,7 @@ addAccessibleTask(
await testCachedRelation(toggleSibling, RELATION_DETAILS, []);
await testCachedRelation(popover, RELATION_DETAILS_FOR, toggle1);
info("Setting toggle2 popovertargetaction");
info("Setting toggle2 popovertarget");
await invokeSetAttribute(browser, "toggle2", "popovertarget", "popover");
await testCachedRelation(toggle2, RELATION_DETAILS, popover);
await testCachedRelation(popover, RELATION_DETAILS_FOR, [toggle1, toggle2]);
@ -364,3 +364,88 @@ addAccessibleTask(
},
{ chrome: false, topLevel: true }
);
/**
* Test details relations for the popoverTargetElement WebIDL attribute.
*/
addAccessibleTask(
`
<button id="toggle1">toggle1</button>
<button id="toggle2">toggle2</button>
between
<div id="popover1" popover>popover1</div>
<button id="toggle3">toggle3</button>
<div id="shadowHost"><template shadowrootmode="open">
<button id="toggle4">toggle4</button>
between
<div id="popover2" popover>popover2</div>
<button id="toggle5">toggle5</button>
</template></div>
<script>
const toggle1 = document.getElementById("toggle1");
const toggle2 = document.getElementById("toggle2");
const popover1 = document.getElementById("popover1");
toggle1.popoverTargetElement = popover1;
toggle2.popoverTargetElement = popover1;
const toggle3 = document.getElementById("toggle3");
const shadow = document.getElementById("shadowHost").shadowRoot;
const toggle4 = shadow.getElementById("toggle4");
const popover2 = shadow.getElementById("popover2");
toggle3.popoverTargetElement = popover2;
toggle4.popoverTargetElement = popover2;
const toggle5 = shadow.getElementById("toggle5");
toggle5.popoverTargetElement = popover1;
</script>
`,
async function testPopoverIdl(browser, docAcc) {
// No popover is showing, so there shouldn't be any details relations.
const toggle1 = findAccessibleChildByID(docAcc, "toggle1");
await testCachedRelation(toggle1, RELATION_DETAILS, []);
const toggle2 = findAccessibleChildByID(docAcc, "toggle2");
await testCachedRelation(toggle2, RELATION_DETAILS, []);
const toggle3 = findAccessibleChildByID(docAcc, "toggle3");
await testCachedRelation(toggle3, RELATION_DETAILS, []);
const toggle4 = findAccessibleChildByID(docAcc, "toggle4");
await testCachedRelation(toggle4, RELATION_DETAILS, []);
const toggle5 = findAccessibleChildByID(docAcc, "toggle5");
await testCachedRelation(toggle5, RELATION_DETAILS, []);
info("Showing popover1");
let shown = waitForEvent(EVENT_SHOW, "popover1");
toggle1.doAction(0);
const popover1 = (await shown).accessible;
await testCachedRelation(toggle1, RELATION_DETAILS, popover1);
await testCachedRelation(toggle2, RELATION_DETAILS, popover1);
// toggle5 is inside the shadow DOM and popover1 is outside, so the target
// is valid.
await testCachedRelation(toggle5, RELATION_DETAILS, popover1);
await testCachedRelation(popover1, RELATION_DETAILS_FOR, [
toggle1,
toggle2,
toggle5,
]);
info("Hiding popover1");
let hidden = waitForEvent(EVENT_HIDE, popover1);
toggle1.doAction(0);
await hidden;
await testCachedRelation(toggle1, RELATION_DETAILS, []);
await testCachedRelation(toggle2, RELATION_DETAILS, []);
await testCachedRelation(toggle5, RELATION_DETAILS, []);
info("Showing popover2");
shown = waitForEvent(EVENT_SHOW, "popover2");
toggle4.doAction(0);
const popover2 = (await shown).accessible;
// toggle4 is in the same shadow DOM as popover2.
await testCachedRelation(toggle4, RELATION_DETAILS, popover2);
// toggle3 is outside popover2's shadow DOM, so the target isn't valid.
await testCachedRelation(toggle3, RELATION_DETAILS, []);
await testCachedRelation(popover2, RELATION_DETAILS_FOR, [toggle4]);
info("Hiding popover2");
hidden = waitForEvent(EVENT_HIDE, popover2);
toggle4.doAction(0);
await hidden;
await testCachedRelation(toggle4, RELATION_DETAILS, []);
},
{ chrome: true, topLevel: true }
);

View file

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
requestLongerTimeout(2);
/* import-globals-from ../../mochitest/role.js */
/* import-globals-from ../../mochitest/states.js */
@ -484,7 +485,7 @@ addAccessibleTask(
);
/**
* Test caching of the expanded state for popover target element.
* Test caching of the expanded state for the popovertarget content attribute.
*/
addAccessibleTask(
`
@ -550,3 +551,79 @@ addAccessibleTask(
},
{ chrome: true, topLevel: true, remoteIframe: true }
);
/**
* Test caching of the expanded state for the popoverTargetElement WebIDL
* attribute.
*/
addAccessibleTask(
`
<button id="toggle1">toggle</button>
<div id="popover1" popover>popover1</div>
<button id="toggle2">toggle2</button>
<button id="toggle3">toggle3</button>
<div id="shadowHost"><template shadowrootmode="open">
<button id="toggle4">toggle4</button>
<div id="popover2" popover>popover2</div>
<button id="toggle5">toggle5</button>
</template></div>
<script>
const toggle1 = document.getElementById("toggle1");
const popover1 = document.getElementById("popover1");
toggle1.popoverTargetElement = popover1;
toggle2.popoverTargetElement = popover1;
const toggle3 = document.getElementById("toggle3");
const shadow = document.getElementById("shadowHost").shadowRoot;
const toggle4 = shadow.getElementById("toggle4");
const popover2 = shadow.getElementById("popover2");
toggle3.popoverTargetElement = popover2;
toggle4.popoverTargetElement = popover2;
const toggle5 = shadow.getElementById("toggle5");
toggle5.popoverTargetElement = popover1;
</script>
`,
async function (browser, docAcc) {
const toggle1 = findAccessibleChildByID(docAcc, "toggle1");
testStates(toggle1, STATE_COLLAPSED);
const toggle2 = findAccessibleChildByID(docAcc, "toggle2");
testStates(toggle2, STATE_COLLAPSED);
const toggle5 = findAccessibleChildByID(docAcc, "toggle5");
// toggle5 is inside the shadow DOM and popover1 is outside, so the target
// is valid.
testStates(toggle5, STATE_COLLAPSED);
// Changes to the popover should fire events on all invokers.
const changeEvents = [
[EVENT_STATE_CHANGE, toggle1],
[EVENT_STATE_CHANGE, toggle2],
[EVENT_STATE_CHANGE, toggle5],
];
info("Showing popover1");
let changed = waitForEvents(changeEvents);
toggle1.doAction(0);
await changed;
testStates(toggle1, STATE_EXPANDED);
testStates(toggle2, STATE_EXPANDED);
info("Hiding popover1");
changed = waitForEvents(changeEvents);
toggle1.doAction(0);
await changed;
testStates(toggle1, STATE_COLLAPSED);
testStates(toggle2, STATE_COLLAPSED);
const toggle3 = findAccessibleChildByID(docAcc, "toggle3");
// toggle3 is outside popover2's shadow DOM, so the target isn't valid.
testStates(
toggle3,
0,
0,
STATE_EXPANDED | STATE_COLLAPSED,
EXT_STATE_EXPANDABLE
);
const toggle4 = findAccessibleChildByID(docAcc, "toggle4");
// toggle4 is in the same shadow DOM as popover2.
testStates(toggle4, STATE_COLLAPSED);
},
{ chrome: true, topLevel: true }
);