Skip to content
Permalink
Browse files
AX: WebKit does not break AX modality when focus is explicitly moved …
…outside the modal

https://bugs.webkit.org/show_bug.cgi?id=246580
rdar://problem/101209793

Reviewed by Chris Fleizach.

https://www.w3.org/TR/wai-aria-1.1/#aria-modal

"If focus moves to an element outside the modal element, assistive technologies SHOULD NOT limit navigation to the modal element."

With this patch, we now follow this recommendation.

* LayoutTests/accessibility/aria-modal-multiple-dialogs-expected.txt:
* LayoutTests/accessibility/aria-modal-multiple-dialogs.html:
* Source/WebCore/accessibility/AXObjectCache.cpp:
(WebCore::AXObjectCache::updateCurrentModalNode):
(WebCore::AXObjectCache::updateCurrentModalNodeInternal):
Deleted. Function body moved to a lambda inside updateCurrentModalNode.
(WebCore::AXObjectCache::performDeferredCacheUpdate):
* Source/WebCore/accessibility/AXObjectCache.h:

Canonical link: https://commits.webkit.org/255665@main
  • Loading branch information
twilco committed Oct 18, 2022
1 parent 8668ed7 commit b6eab27a3f8dd2391c8d6128e711b70f8e5fa31a
Show file tree
Hide file tree
Showing 4 changed files with 57 additions and 40 deletions.
@@ -22,6 +22,10 @@ PASS: background accessible: false
PASS: #dialog1 accessible: true
PASS: #dialog2 accessible: false

Focusing on background.

PASS: background accessible: true

Moving focus back to first descendant of #dialog2.

PASS: background accessible: false
@@ -51,6 +51,12 @@ <h3 id="description2">Another dialog.</h3>
await dialog1Accessible(true);
await dialog2Accessible(false);

// https://www.w3.org/TR/wai-aria-1.1/#aria-modal
// "If focus moves to an element outside the modal element, assistive technologies SHOULD NOT limit navigation to the modal element."
testOutput += "\nFocusing on background.\n\n";
document.getElementById("textfield").focus();
await backgroundAccessible(true);

testOutput += "\nMoving focus back to first descendant of #dialog2.\n\n";
focusFirstDescendant(document.getElementById("dialog2"));
await backgroundAccessible(false);
@@ -309,10 +309,52 @@ bool AXObjectCache::modalElementHasAccessibleContent(Element& element)
return false;
}

void AXObjectCache::updateCurrentModalNode()
void AXObjectCache::updateCurrentModalNode(WillRecomputeFocus willRecomputeFocus)
{
auto recomputeModalElement = [&] () -> Element* {
// There might be multiple modal dialog nodes.
// We use this function to pick the one we want.
if (m_modalElements.isEmpty())
return nullptr;

// Pick the document active modal <dialog> element if it exists.
if (Element* activeModalDialog = document().activeModalDialog()) {
ASSERT(m_modalElements.contains(activeModalDialog));
return activeModalDialog;
}

SetForScope retrievingCurrentModalNode(m_isRetrievingCurrentModalNode, true);
// If any of the modal nodes contains the keyboard focus, we want to pick that one.
// If not, we want to pick the last visible dialog in the DOM.
RefPtr<Element> focusedElement = document().focusedElement();
bool focusedElementIsOutsideModals = focusedElement;
RefPtr<Element> lastVisible;
for (auto& element : m_modalElements) {
// Elements in m_modalElementsSet may have become un-modal since we added them, but not yet removed
// as part of the asynchronous m_deferredModalChangedList handling. Skip these.
if (!element || !isModalElement(*element))
continue;

// To avoid trapping users in an empty modal, skip any non-visible element, or any element without accessible content.
if (!isNodeVisible(element.get()) || !modalElementHasAccessibleContent(*element))
continue;

lastVisible = element.get();
if (focusedElement && focusedElement->isDescendantOf(*element)) {
focusedElementIsOutsideModals = false;
break;
}
}

// If there is a focused element, and it's not inside any of the modals, we should
// consider all modals inactive to allow the user to freely navigate.
if (focusedElementIsOutsideModals && willRecomputeFocus == WillRecomputeFocus::No)
return nullptr;
return lastVisible.get();
};

auto* previousModal = m_currentModalElement.get();
m_currentModalElement = updateCurrentModalNodeInternal();
m_currentModalElement = recomputeModalElement();
if (previousModal != m_currentModalElement.get()) {
childrenChanged(rootWebArea());
#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
@@ -323,41 +365,6 @@ void AXObjectCache::updateCurrentModalNode()
}
}

Element* AXObjectCache::updateCurrentModalNodeInternal()
{
// There might be multiple modal dialog nodes.
// We use this function to pick the one we want.
if (m_modalElements.isEmpty())
return nullptr;

// Pick the document active modal <dialog> element if it exists.
if (Element* activeModalDialog = document().activeModalDialog()) {
ASSERT(m_modalElements.contains(activeModalDialog));
return activeModalDialog;
}

SetForScope retrievingCurrentModalNode(m_isRetrievingCurrentModalNode, true);
// If any of the modal nodes contains the keyboard focus, we want to pick that one.
// If not, we want to pick the last visible dialog in the DOM.
RefPtr<Element> focusedElement = document().focusedElement();
RefPtr<Element> lastVisible;
for (auto& element : m_modalElements) {
// Elements in m_modalElementsSet may have become un-modal since we added them, but not yet removed
// as part of the asynchronous m_deferredModalChangedList handling. Skip these.
if (!element || !isModalElement(*element))
continue;

// To avoid trapping users in an empty modal, skip any non-visible element, or any element without accessible content.
if (!isNodeVisible(element.get()) || !modalElementHasAccessibleContent(*element))
continue;

lastVisible = element.get();
if (focusedElement && focusedElement->isDescendantOf(*element))
break;
}
return lastVisible.get();
}

bool AXObjectCache::isNodeVisible(Node* node) const
{
if (!is<Element>(node))
@@ -3576,7 +3583,7 @@ void AXObjectCache::performDeferredCacheUpdate()
m_deferredModalChangedList.clear();

if (shouldRecomputeModal) {
updateCurrentModalNode();
updateCurrentModalNode(updatedFocusedElement ? WillRecomputeFocus::No : WillRecomputeFocus::Yes);
// "When a modal element is displayed, assistive technologies SHOULD navigate to the element unless focus has explicitly been set elsewhere."
// `updatedFocusedElement` indicates focus was explicitly set elsewhere, so don't autofocus into the modal.
// https://w3c.github.io/aria/#aria-modal
@@ -513,8 +513,8 @@ class AXObjectCache {
// aria-modal or modal <dialog> related
bool isModalElement(Element&) const;
void findModalNodes();
void updateCurrentModalNode();
Element* updateCurrentModalNodeInternal();
enum class WillRecomputeFocus : bool { No, Yes };
void updateCurrentModalNode(WillRecomputeFocus = WillRecomputeFocus::No);
bool isNodeVisible(Node*) const;
bool modalElementHasAccessibleContent(Element&);

0 comments on commit b6eab27

Please sign in to comment.