Title: [295366] trunk
Revision
295366
Author
tyle...@apple.com
Date
2022-06-07 14:45:25 -0700 (Tue, 07 Jun 2022)

Log Message

AX: WebKit does not trap user focus inside modals that have been DOM moved
https://bugs.webkit.org/show_bug.cgi?id=240978

Reviewed by Andres Gonzalez.

In this patch, we now properly trap user focus inside non-empty modals
that have been DOM moved. This pattern is used in the WAI-ARIA aria-modal
usage example:

https://w3c.github.io/aria-practices/examples/dialog-modal/dialog.html

We do this by re-computing the live and isolated trees when the active
modal changes, since the presence of a modal effects every element on
the page.

This patch also includes several other modal improvements:
  - We no longer sometimes overwrite author manual focus (e.g. via JS)
    with our modal autofocus behavior.

  - We now properly re-compute the active modal when focus changes. This
    is relevant in cases where there are multiple modals, and an author
    changes focus from one to another, making the latter the new active
    modal.

This patch removes m_focusModalNodeTimer in favor of a new function
called focusCurrentModal(), which allows precise and synchronous control
over when modal autofocus happens. The asynchronous nature of the timer
made it hard to prevent overwriting manual author focus (e.g. via JS).

This patch fixes existing tests in ITM:
  - accessibility/aria-modal-multiple-dialogs.html
  - accessibility/ignore-modals-without-any-content.html
  - accessibility/mac/aria-modal-auto-focus.html

And adds a new test:
accessibility/recompute-current-modal-after-aria-modal-element-appears.html

* LayoutTests/accessibility/aria-modal-multiple-dialogs-expected.txt:
* LayoutTests/accessibility/aria-modal-multiple-dialogs.html:
* LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears-expected.txt: Added.
* LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears.html: Added.
* Source/WebCore/accessibility/AXObjectCache.cpp:
(WebCore::AXObjectCache::AXObjectCache):
(WebCore::AXObjectCache::~AXObjectCache):
(WebCore::AXObjectCache::updateCurrentModalNode):
(WebCore::AXObjectCache::updateCurrentModalNodeInternal):
(WebCore::AXObjectCache::modalNode):
(WebCore::AXObjectCache::deferNodeAddedOrRemoved):
(WebCore::AXObjectCache::handleFocusedUIElementChanged):
(WebCore::AXObjectCache::focusCurrentModal):
(WebCore::AXObjectCache::performDeferredCacheUpdate):
(WebCore::AXObjectCache::currentModalNode): Deleted.
(WebCore::AXObjectCache::focusModalNode): Deleted.
(WebCore::AXObjectCache::focusModalNodeTimerFired): Deleted.
(WebCore::AXObjectCache::handleModalChange): Deleted.
* Source/WebCore/accessibility/AXObjectCache.h:
(WebCore::AXObjectCache::AXObjectCache):
(WebCore::AXObjectCache::focusCurrentModal):
(WebCore::AXObjectCache::handleAriaExpandedChange):
(WebCore::AXObjectCache::handleFocusedUIElementChanged):
(WebCore::AXObjectCache::focusModalNodeTimerFired): Deleted.
(WebCore::AXObjectCache::handleModalChange): Deleted.

Canonical link: https://commits.webkit.org/251374@main

Modified Paths

Added Paths

Diff

Modified: trunk/LayoutTests/accessibility/aria-modal-multiple-dialogs-expected.txt (295365 => 295366)


--- trunk/LayoutTests/accessibility/aria-modal-multiple-dialogs-expected.txt	2022-06-07 21:31:50 UTC (rev 295365)
+++ trunk/LayoutTests/accessibility/aria-modal-multiple-dialogs-expected.txt	2022-06-07 21:45:25 UTC (rev 295366)
@@ -1,20 +1,46 @@
 This tests that aria-modal works correctly on multiple dialogs
 
-On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
 
+Verifying the background is accessible on page load.
 
-PASS backgroundAccessible() is true
-PASS backgroundAccessible() is false
-PASS dialog1Accessible() is true
-PASS backgroundAccessible() is false
-PASS dialog1Accessible() is false
-PASS closeBtn.isIgnored is false
-PASS backgroundAccessible() is false
-PASS dialog1Accessible() is true
-PASS backgroundAccessible() is true
+PASS: background accessible: true
+
+Clicking the display button to open #dialog1.
+
+PASS: background accessible: false
+PASS: #dialog1 accessible: true
+
+Clicking the new button to open #dialog2 without closing #dialog1.
+
+PASS: background accessible: false
+PASS: #dialog1 accessible: false
+PASS: #dialog2 accessible: true
+
+Focusing first descendant of #dialog1.
+
+PASS: background accessible: false
+PASS: #dialog1 accessible: true
+PASS: #dialog2 accessible: false
+
+Moving focus back to first descendant of #dialog2.
+
+PASS: background accessible: false
+PASS: #dialog1 accessible: false
+PASS: #dialog2 accessible: true
+
+Closing dialog2.
+
+PASS: background accessible: false
+PASS: #dialog1 accessible: true
+
+Closing dialog1.
+
+PASS: background accessible: true
+
 PASS successfullyParsed is true
 
 TEST COMPLETE
+
 Other page content with a dummy focusable element
 
 Display a dialog

Modified: trunk/LayoutTests/accessibility/aria-modal-multiple-dialogs.html (295365 => 295366)


--- trunk/LayoutTests/accessibility/aria-modal-multiple-dialogs.html	2022-06-07 21:31:50 UTC (rev 295365)
+++ trunk/LayoutTests/accessibility/aria-modal-multiple-dialogs.html	2022-06-07 21:45:25 UTC (rev 295366)
@@ -1,94 +1,133 @@
 <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
 <html>
 <head>
-<script src=""
+<script src=""
+<script src=""
 </head>
 
-<style>
-.box-hidden {
-    display: none;
-}
-</style>
+<body>
 
-<body id="body">
+<input type="text" id="textfield" value="Text field.">
+<p id="bgContent">Other page content with <a id="background-link" tabindex="0" href="" dummy focusable element</a></p>
+<p><a _onclick_="toggleDialog(document.getElementById('dialog1'), 'show'); return false;" href="" role="button" id="displayBtn">Display a dialog</a></p>
 
-<div id="bg">
-<p id="bgContent">Other page content with <a href="" dummy focusable element</a></p>
-<p><a _onclick_="toggleDialog(document.getElementById('box'),'show'); return false;" href="" role="button" id="displayBtn">Display a dialog</a></p>
+<div role="dialog" aria-labelledby="description1" id="dialog1" style="display: none" tabindex="-1">
+    <h3 id="description1">Just an example.</h3>
+    <a id="dialog1-link" tabindex="0" href="" present to ensure we have a focusable element</a>
+    <button id="ok" _onclick_="toggleDialog(document.getElementById('dialog1'), 'hide');">OK</button>
+    <button _onclick_="toggleDialog(document.getElementById('dialog2'), 'show');" id="new">New</button>
 </div>
 
-<div role="dialog" aria-labelledby="myDialog" id="box" class="box-hidden" tabindex="-1">
-    <h3 id="myDialog">Just an example.</h3>
-    <button id="ok" _onclick_="toggleDialog(document.getElementById('box'),'hide');" class="close-button">OK</button>
-    <button _onclick_="toggleDialog(document.getElementById('box2'),'show');" id="new">New</button>
+<div role="dialog" aria-labelledby="description2" id="dialog2" style="display: none" tabindex="-1">
+    <h3 id="description2">Another dialog.</h3>
+    <a id="dialog2-link" tabindex="0" href="" present to ensure we have a focusable element</a>
+    <button id="close" _onclick_="toggleDialog(document.getElementById('dialog2'), 'hide');">Close</button>
 </div>
 
-<div role="dialog" aria-labelledby="myDialog2" id="box2" class="box-hidden" tabindex="-1">
-    <h3 id="myDialog2">Another dialog.</h3>
-    <button id="close" _onclick_="toggleDialog(document.getElementById('box2'),'hide');" class="close-button">Close</button>
-</div>
+<script>
+    var testOutput = "This tests that aria-modal works correctly on multiple dialogs\n\n";
 
+    if (window.accessibilityController) {
+        window.jsTestIsAsync = true;
+        setTimeout(async () => {
+            testOutput += "\nVerifying the background is accessible on page load.\n\n";
+            await backgroundAccessible(true);
 
-<script>
+            testOutput += "\nClicking the display button to open #dialog1.\n\n";
+            document.getElementById("displayBtn").click();
+            await backgroundAccessible(false);
+            await dialog1Accessible(true);
 
-    description("This tests that aria-modal works correctly on multiple dialogs");
+            testOutput += "\nClicking the new button to open #dialog2 without closing #dialog1.\n\n";
+            document.getElementById("new").click();
+            await backgroundAccessible(false);
+            await dialog1Accessible(false);
+            await dialog2Accessible(true);
 
-    if (window.accessibilityController) {
-        // Background should be accessible after loading.
-        shouldBeTrue("backgroundAccessible()");
-        
-        // Click the display button, dialog1 shows and background becomes unaccessible.
-        document.getElementById("displayBtn").click();
-        shouldBeFalse("backgroundAccessible()");
-        shouldBeTrue("dialog1Accessible()");
-        
-        // Click the new button, dialog2 shows and background/dialog1 should both be unaccessible.
-        document.getElementById("new").click();
-        shouldBeFalse("backgroundAccessible()");
-        shouldBeFalse("dialog1Accessible()");
-        var closeBtn = accessibilityController.accessibleElementById("close");
-        shouldBeFalse("closeBtn.isIgnored");
-        
-        // Close dialog2, dialog1 should become accessible but not the background
-        document.getElementById("close").click();
-        shouldBeFalse("backgroundAccessible()");
-        shouldBeTrue("dialog1Accessible()");
-        
-        // Close dialog1, background should be accessible.
-        document.getElementById("ok").click();
-        shouldBeTrue("backgroundAccessible()");
+            // With both modals active, and focus currently in #dialog2, moving focus to #dialog1 should cause it to become the active modal.
+            testOutput += "\nFocusing first descendant of #dialog1.\n\n";
+            focusFirstDescendant(document.getElementById("dialog1"));
+            await backgroundAccessible(false);
+            await dialog1Accessible(true);
+            await dialog2Accessible(false);
+
+            testOutput += "\nMoving focus back to first descendant of #dialog2.\n\n";
+            focusFirstDescendant(document.getElementById("dialog2"));
+            await backgroundAccessible(false);
+            await dialog1Accessible(false);
+            await dialog2Accessible(true);
+
+            testOutput += "\nClosing dialog2.\n\n";
+            document.getElementById("close").click();
+            await backgroundAccessible(false);
+            await dialog1Accessible(true);
+
+            testOutput += "\nClosing dialog1.\n\n";
+            document.getElementById("ok").click();
+            await backgroundAccessible(true);
+
+            debug(testOutput);
+            finishJSTest();
+        });
     }
     
-    function backgroundAccessible() {
-        var displayBtn = accessibilityController.accessibleElementById("displayBtn");
-        var bgContent = accessibilityController.accessibleElementById("bgContent");
-        if (!displayBtn || !bgContent)
-            return false;
-        return !displayBtn.isIgnored && !bgContent.isIgnored;
+    async function backgroundAccessible(shouldBeAccessible) {
+        await waitFor(() => {
+            const displayBtn = accessibilityController.accessibleElementById("displayBtn");
+            const bgContent = accessibilityController.accessibleElementById("bgContent");
+            if (!displayBtn || !bgContent)
+                return !shouldBeAccessible;
+            return (!displayBtn.isIgnored && !bgContent.isIgnored) === shouldBeAccessible;
+        });
+        testOutput += `PASS: background accessible: ${shouldBeAccessible}\n`
     }
     
-    function dialog1Accessible() {
-         var okBtn = accessibilityController.accessibleElementById("ok");
-         var newBtn = accessibilityController.accessibleElementById("new");
-         if (!okBtn || !newBtn)
-             return false;
-         return !okBtn.isIgnored && !newBtn.isIgnored;
+    async function dialog1Accessible(shouldBeAccessible) {
+        await waitFor(() => {
+            const okBtn = accessibilityController.accessibleElementById("ok");
+            const newBtn = accessibilityController.accessibleElementById("new");
+            if (!okBtn || !newBtn)
+                return !shouldBeAccessible;
+            return (!okBtn.isIgnored && !newBtn.isIgnored) === shouldBeAccessible;
+        });
+        testOutput += `PASS: #dialog1 accessible: ${shouldBeAccessible}\n`
     }
+
+    async function dialog2Accessible(shouldBeAccessible) {
+        await waitFor(() => {
+            const closeButton = accessibilityController.accessibleElementById("close");
+            if (!closeButton)
+                return !shouldBeAccessible;
+            return closeButton.isIgnored !== shouldBeAccessible;
+        });
+        testOutput += `PASS: #dialog2 accessible: ${shouldBeAccessible}\n`
+    }
     
     function toggleDialog(dialog, sh) {
-    if (sh == "show") {
-        // show the dialog 
-        dialog.style.display = 'block';
-        dialog.setAttribute("aria-modal", "true");
-    } else {
-        dialog.style.display = 'none';
-        dialog.setAttribute("aria-modal", "false"); 
+        if (sh == "show") {
+            dialog.style.display = "block";
+            dialog.setAttribute("aria-modal", "true");
+            // Put focus inside the new dialog so it takes precedence over other dialogs (even if they come later in DOM order).
+            focusFirstDescendant(dialog);
+        } else {
+            dialog.style.display = "none";
+            dialog.setAttribute("aria-modal", "false");
+        }
     }
-}
 
+    function focusFirstDescendant(element) {
+        for (let i = 0; i < element.childNodes.length; i++) {
+            const child = element.childNodes[i];
+            if (!attemptFocus(child))
+                focusFirstDescendant(child)
+        }
+    };
+
+    function attemptFocus(element) {
+        try { element.focus() } catch (e) {}
+        return document.activeElement === element;
+    }
 </script>
+</body>
+</html>
 
-
-<script src=""
-</body>
-</html>
\ No newline at end of file

Added: trunk/LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears-expected.txt (0 => 295366)


--- trunk/LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears-expected.txt	                        (rev 0)
+++ trunk/LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears-expected.txt	2022-06-07 21:45:25 UTC (rev 295366)
@@ -0,0 +1,15 @@
+This test ensures that we update the page-wide active modal when an aria-modal element is dynamically added and removed.
+
+PASS: Modal content is not initially accessible.
+
+Un-hiding aria-modal element.
+PASS: Background is inaccessible, modal content is accessible.
+
+Re-hiding aria-modal element.
+PASS: Background is accessible, modal content is inaccessible.
+
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
+

Added: trunk/LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears.html (0 => 295366)


--- trunk/LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears.html	                        (rev 0)
+++ trunk/LayoutTests/accessibility/recompute-current-modal-after-aria-modal-element-appears.html	2022-06-07 21:45:25 UTC (rev 295366)
@@ -0,0 +1,63 @@
+<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
+<html>
+<head>
+<script src=""
+<script src=""
+</head>
+<body>
+
+<div id="content">
+    <p id="p-before-modal">Page content before modal</p>
+
+    <div id="modal" aria-modal="true" role="dialog" style="display: none">
+        <p id="p-inside-modal">Page content inside modal</p>
+        <button id="close-button">Close modal button</button>
+    </div>
+    <div id="new-modal-container"></div>
+
+    <p id="p-after-modal">Page content after modal</p>
+</div>
+
+<script>
+    var testOutput = "This test ensures that we update the page-wide active modal when an aria-modal element is dynamically added and removed.\n\n";
+
+    function backgroundIsAccessible() {
+        return accessibilityController.accessibleElementById("p-before-modal") &&
+            accessibilityController.accessibleElementById("p-after-modal");
+    }
+    function modalContentIsAccessible() {
+        return accessibilityController.accessibleElementById("p-inside-modal") &&
+            accessibilityController.accessibleElementById("close-button");
+    }
+
+    if (window.accessibilityController) {
+        window.jsTestIsAsync = true;
+
+        if (backgroundIsAccessible() && !modalContentIsAccessible())
+            testOutput += "PASS: Modal content is not initially accessible.\n";
+        else
+            testOutput += "FAIL: Model content is initially accessible.\n";
+
+        testOutput += `\nUn-hiding aria-modal element.\n`;
+        // Move the modal in the DOM. This is the key thing being tested -- do we properly preserve modal behavior
+        // for a modal that has been moved?
+        document.getElementById("new-modal-container").appendChild(document.getElementById("modal"))
+        document.getElementById("modal").removeAttribute("style");
+        setTimeout(async function() {
+            await waitFor(() => { return !backgroundIsAccessible() && modalContentIsAccessible() });
+            testOutput += "PASS: Background is inaccessible, modal content is accessible.\n";
+
+            testOutput += `\nRe-hiding aria-modal element.\n`;
+            document.getElementById("modal").style.display = "none";
+            await waitFor(() => { return backgroundIsAccessible() });
+            testOutput += "PASS: Background is accessible, modal content is inaccessible.\n";
+
+            document.getElementById("content").style.visibility = "hidden";
+            debug(testOutput);
+            finishJSTest();
+        }, 0);
+    }
+</script>
+</body>
+</html>
+

Modified: trunk/Source/WebCore/accessibility/AXObjectCache.cpp (295365 => 295366)


--- trunk/Source/WebCore/accessibility/AXObjectCache.cpp	2022-06-07 21:31:50 UTC (rev 295365)
+++ trunk/Source/WebCore/accessibility/AXObjectCache.cpp	2022-06-07 21:45:25 UTC (rev 295366)
@@ -222,7 +222,6 @@
     , m_notificationPostTimer(*this, &AXObjectCache::notificationPostTimerFired)
     , m_passwordNotificationPostTimer(*this, &AXObjectCache::passwordNotificationPostTimerFired)
     , m_liveRegionChangedPostTimer(*this, &AXObjectCache::liveRegionChangedNotificationPostTimerFired)
-    , m_focusModalNodeTimer(*this, &AXObjectCache::focusModalNodeTimerFired)
     , m_currentModalElement(nullptr)
     , m_performCacheUpdateTimer(*this, &AXObjectCache::performCacheUpdateTimerFired)
 {
@@ -240,7 +239,6 @@
 {
     m_notificationPostTimer.stop();
     m_liveRegionChangedPostTimer.stop();
-    m_focusModalNodeTimer.stop();
     m_performCacheUpdateTimer.stop();
 
     for (const auto& object : m_objects.values())
@@ -296,11 +294,24 @@
     return false;
 }
 
-Element* AXObjectCache::currentModalNode()
+void AXObjectCache::updateCurrentModalNode()
 {
+    auto* previousModal = m_currentModalElement.get();
+    m_currentModalElement = updateCurrentModalNodeInternal();
+    if (previousModal != m_currentModalElement.get()) {
+        childrenChanged(rootWebArea());
+#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
+        // Because the presence of a modal affects every element on the page,
+        // regenerate the entire isolated tree with the next cache update.
+        m_deferredRegenerateIsolatedTree = true;
+#endif
+    }
+}
+
+Element* AXObjectCache::updateCurrentModalNodeInternal()
+{
     // There might be multiple modal dialog nodes.
     // We use this function to pick the one we want.
-    m_currentModalElement = nullptr;
     if (m_modalElementsSet.isEmpty())
         return nullptr;
 
@@ -325,17 +336,11 @@
         if (!isNodeVisible(element) || !modalElementHasAccessibleContent(*element))
             continue;
 
-        if (focusedElement && focusedElement->isDescendantOf(element)) {
-            m_currentModalElement = element;
+        lastVisible = element;
+        if (focusedElement && focusedElement->isDescendantOf(element))
             break;
-        }
-        lastVisible = element;
     }
-
-    if (!m_currentModalElement)
-        m_currentModalElement = lastVisible.get();
-
-    return m_currentModalElement.get();
+    return lastVisible.get();
 }
 
 bool AXObjectCache::isNodeVisible(Node* node) const
@@ -385,7 +390,8 @@
         return m_currentModalElement.get();
 
     // Recompute the valid aria modal node when m_currentModalElement is null or hidden.
-    return currentModalNode();
+    updateCurrentModalNode();
+    return m_currentModalElement.get();
 }
 
 AccessibilityObject* AXObjectCache::focusedImageMapUIElement(HTMLAreaElement* areaElement)
@@ -1119,6 +1125,12 @@
 
     m_deferredNodeAddedOrRemovedList.add(node);
 
+    if (is<Element>(node)) {
+        auto* changedElement = downcast<Element>(node);
+        if (isModalElement(*changedElement))
+            deferModalChange(changedElement);
+    }
+
     if (!m_performCacheUpdateTimer.isActive())
         m_performCacheUpdateTimer.startOneShot(0_s);
 }
@@ -1351,12 +1363,14 @@
         m_performCacheUpdateTimer.startOneShot(0_s);
 }
     
-void AXObjectCache::handleFocusedUIElementChanged(Node* oldNode, Node* newNode)
+void AXObjectCache::handleFocusedUIElementChanged(Node* oldNode, Node* newNode, UpdateModal updateModal)
 {
 #if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
     setIsolatedTreeFocusedObject(newNode);
 #endif
 
+    if (updateModal == UpdateModal::Yes)
+        updateCurrentModalNode();
     handleMenuItemSelected(newNode);
     platformHandleFocusedUIElementChanged(oldNode, newNode);
 }
@@ -1759,16 +1773,8 @@
     return nullptr;
 }
 
-void AXObjectCache::focusModalNode()
+void AXObjectCache::focusCurrentModal()
 {
-    if (m_focusModalNodeTimer.isActive())
-        m_focusModalNodeTimer.stop();
-    
-    m_focusModalNodeTimer.startOneShot(0_s);
-}
-
-void AXObjectCache::focusModalNodeTimerFired()
-{
     if (!m_document.hasLivingRenderTree())
         return;
 
@@ -1775,6 +1781,10 @@
     Ref<Document> protectedDocument(m_document);
     if (!nodeAndRendererAreValid(m_currentModalElement.get()) || !isNodeVisible(m_currentModalElement.get()))
         return;
+
+    // Don't focus the current modal if focus has been requested to be put elsewhere (e.g. via JS).
+    if (!m_deferredFocusedNodeChange.isEmpty())
+        return;
     
     // Don't set focus if we are already focusing onto some element within
     // the dialog.
@@ -1979,34 +1989,6 @@
         postNotification(element, AXObjectCache::AXSortDirectionChanged);
 }
 
-void AXObjectCache::handleModalChange(Element& element)
-{
-    if (!is<HTMLDialogElement>(element) && !nodeHasRole(&element, "dialog"_s) && !nodeHasRole(&element, "alertdialog"_s))
-        return;
-
-    stopCachingComputedObjectAttributes();
-
-    if (!m_modalNodesInitialized)
-        findModalNodes();
-
-    if (isModalElement(element)) {
-        // Add the newly modified node to the modal nodes set.
-        // We will recompute the current valid aria modal node in modalNode() when this node is not visible.
-        m_modalElementsSet.add(&element);
-    } else {
-        // Remove the node from the modal nodes set.
-        m_modalElementsSet.remove(&element);
-    }
-
-    // Find new active modal node.
-    currentModalNode();
-
-    if (m_currentModalElement)
-        focusModalNode();
-
-    startCachingComputedObjectAttributesUntilTreeMutates();
-}
-
 void AXObjectCache::labelChanged(Element* element)
 {
     ASSERT(is<HTMLLabelElement>(*element));
@@ -3393,23 +3375,60 @@
     m_deferredAttributeChange.clear();
     
     for (auto& deferredFocusedChangeContext : m_deferredFocusedNodeChange) {
-        handleFocusedUIElementChanged(deferredFocusedChangeContext.first, deferredFocusedChangeContext.second);
+        // Don't recompute the active modal for each individal focus change, as that could cause a lot of expensive tree rebuilding. Instead, we do it once below.
+        handleFocusedUIElementChanged(deferredFocusedChangeContext.first, deferredFocusedChangeContext.second, UpdateModal::No);
         // Recompute isIgnored after a focus change in case that altered visibility.
         recomputeIsIgnored(deferredFocusedChangeContext.first);
         recomputeIsIgnored(deferredFocusedChangeContext.second);
     }
+    bool updatedFocusedElement = !m_deferredFocusedNodeChange.isEmpty();
+    // If we changed the focused element, that could affect what modal should be active, so recompute it.
+    bool shouldRecomputeModal = updatedFocusedElement;
     m_deferredFocusedNodeChange.clear();
 
-    m_deferredModalChangedList.forEach([this] (auto& deferredModalChangedElement) {
-        handleModalChange(deferredModalChangedElement);
-    });
+    for (auto& element : m_deferredModalChangedList) {
+        if (!is<HTMLDialogElement>(element) && !nodeHasRole(&element, "dialog"_s) && !nodeHasRole(&element, "alertdialog"_s))
+            continue;
+
+        shouldRecomputeModal = true;
+        if (!m_modalNodesInitialized)
+            findModalNodes();
+
+        if (isModalElement(element)) {
+            // Add the newly modified node to the modal nodes set.
+            // We will recompute the current valid aria modal node in modalNode() when this node is not visible.
+            m_modalElementsSet.add(&element);
+        } else
+            m_modalElementsSet.remove(&element);
+    }
     m_deferredModalChangedList.clear();
 
+    if (shouldRecomputeModal) {
+        updateCurrentModalNode();
+        // "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
+        if (!updatedFocusedElement)
+            focusCurrentModal();
+    }
+
     m_deferredMenuListChange.forEach([this] (auto& element) {
         handleMenuListValueChanged(element);
     });
     m_deferredMenuListChange.clear();
 
+#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
+    if (m_deferredRegenerateIsolatedTree && m_pageID) {
+        if (auto tree = AXIsolatedTree::treeForPageID(*m_pageID)) {
+            if (auto* webArea = rootWebArea()) {
+                AXLOG("Regenerating isolated tree from AXObjectCache::performDeferredCacheUpdate().");
+                tree->generateSubtree(*webArea);
+            }
+        }
+    }
+    m_deferredRegenerateIsolatedTree = false;
+#endif
+
     platformPerformDeferredCacheUpdate();
 }
 

Modified: trunk/Source/WebCore/accessibility/AXObjectCache.h (295365 => 295366)


--- trunk/Source/WebCore/accessibility/AXObjectCache.h	2022-06-07 21:31:50 UTC (rev 295365)
+++ trunk/Source/WebCore/accessibility/AXObjectCache.h	2022-06-07 21:45:25 UTC (rev 295366)
@@ -342,7 +342,6 @@
     void postTextStateChangeNotification(Node*, const AXTextStateChangeIntent&, const VisibleSelection&);
     void postTextStateChangeNotification(const Position&, const AXTextStateChangeIntent&, const VisibleSelection&);
     void postLiveRegionChangeNotification(AccessibilityObject*);
-    void focusModalNode();
 
     enum AXLoadingEvent {
         AXLoadingStarted,
@@ -455,7 +454,7 @@
 
     void liveRegionChangedNotificationPostTimerFired();
     
-    void focusModalNodeTimerFired();
+    void focusCurrentModal();
     
     void performCacheUpdateTimerFired();
 
@@ -481,15 +480,16 @@
     void handleActiveDescendantChanged(Element&);
 
     void handleAriaExpandedChange(Node*);
-    void handleFocusedUIElementChanged(Node* oldFocusedNode, Node* newFocusedNode);
+    enum class UpdateModal : bool { No, Yes };
+    void handleFocusedUIElementChanged(Node* oldFocusedNode, Node* newFocusedNode, UpdateModal = UpdateModal::Yes);
     void handleMenuListValueChanged(Element&);
 
     // aria-modal or modal <dialog> related
     bool isModalElement(Element&) const;
     void findModalNodes();
-    Element* currentModalNode();
+    void updateCurrentModalNode();
+    Element* updateCurrentModalNodeInternal();
     bool isNodeVisible(Node*) const;
-    void handleModalChange(Element&);
     bool modalElementHasAccessibleContent(Element&);
 
     // Relationships between objects.
@@ -532,7 +532,6 @@
     Timer m_liveRegionChangedPostTimer;
     ListHashSet<RefPtr<AccessibilityObject>> m_liveRegionObjectsSet;
 
-    Timer m_focusModalNodeTimer;
     WeakPtr<Element> m_currentModalElement;
     // Multiple aria-modals behavior is undefined by spec. We keep them sorted based on DOM order here.
     // If that changes to require only one aria-modal we could change this to a WeakHashSet, or discard the set completely.
@@ -554,6 +553,9 @@
     HashMap<Element*, String> m_deferredTextFormControlValue;
     HashMap<Element*, QualifiedName> m_deferredAttributeChange;
     Vector<std::pair<Node*, Node*>> m_deferredFocusedNodeChange;
+#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
+    bool m_deferredRegenerateIsolatedTree { false };
+#endif
     bool m_isSynchronizingSelection { false };
     bool m_performingDeferredCacheUpdate { false };
     double m_loadingProgress { 0 };
@@ -588,7 +590,7 @@
 inline AccessibilityReplacedText::AccessibilityReplacedText(const VisibleSelection&) { }
 inline void AccessibilityReplacedText::postTextStateChangeNotification(AXObjectCache*, AXTextEditType, const String&, const VisibleSelection&) { }
 inline void AXComputedObjectAttributeCache::setIgnored(AXID, AccessibilityObjectInclusion) { }
-inline AXObjectCache::AXObjectCache(Document& document) : m_document(document), m_notificationPostTimer(*this, &AXObjectCache::notificationPostTimerFired), m_passwordNotificationPostTimer(*this, &AXObjectCache::passwordNotificationPostTimerFired), m_liveRegionChangedPostTimer(*this, &AXObjectCache::liveRegionChangedNotificationPostTimerFired), m_focusModalNodeTimer(*this, &AXObjectCache::focusModalNodeTimerFired), m_performCacheUpdateTimer(*this, &AXObjectCache::performCacheUpdateTimerFired) { }
+inline AXObjectCache::AXObjectCache(Document& document) : m_document(document), m_notificationPostTimer(*this, &AXObjectCache::notificationPostTimerFired), m_passwordNotificationPostTimer(*this, &AXObjectCache::passwordNotificationPostTimerFired), m_liveRegionChangedPostTimer(*this, &AXObjectCache::liveRegionChangedNotificationPostTimerFired), m_performCacheUpdateTimer(*this, &AXObjectCache::performCacheUpdateTimerFired) { }
 inline AXObjectCache::~AXObjectCache() { }
 inline AccessibilityObject* AXObjectCache::get(RenderObject*) { return nullptr; }
 inline AccessibilityObject* AXObjectCache::get(Node*) { return nullptr; }
@@ -623,19 +625,18 @@
 #if !PLATFORM(COCOA) && !USE(ATSPI)
 inline void AXObjectCache::detachWrapper(AXCoreObject*, AccessibilityDetachmentType) { }
 #endif
-inline void AXObjectCache::focusModalNodeTimerFired() { }
+inline void AXObjectCache::focusCurrentModal() { }
 inline void AXObjectCache::performCacheUpdateTimerFired() { }
 inline void AXObjectCache::frameLoadingEventNotification(Frame*, AXLoadingEvent) { }
 inline void AXObjectCache::frameLoadingEventPlatformNotification(AccessibilityObject*, AXLoadingEvent) { }
 inline void AXObjectCache::handleActiveDescendantChanged(Element&) { }
 inline void AXObjectCache::handleAriaExpandedChange(Node*) { }
-inline void AXObjectCache::handleModalChange(Element&) { }
 inline void AXObjectCache::deferModalChange(Element*) { }
 inline void AXObjectCache::handleRoleChange(AccessibilityObject*) { }
 inline void AXObjectCache::deferAttributeChangeIfNeeded(const QualifiedName&, Element*) { }
 inline void AXObjectCache::handleAttributeChange(const QualifiedName&, Element*) { }
 inline bool AXObjectCache::shouldProcessAttributeChange(const QualifiedName&, Element*) { return false; }
-inline void AXObjectCache::handleFocusedUIElementChanged(Node*, Node*) { }
+inline void AXObjectCache::handleFocusedUIElementChanged(Node*, Node*, UpdateModal) { }
 inline void AXObjectCache::handleScrollbarUpdate(ScrollView*) { }
 inline void AXObjectCache::handleScrolledToAnchor(const Node*) { }
 inline void AXObjectCache::liveRegionChangedNotificationPostTimerFired() { }
_______________________________________________
webkit-changes mailing list
webkit-changes@lists.webkit.org
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to