Bug 1488109: If the focused element has aria-activedescendant and the target id is moved to another node, move accessible focus to the new target. r=surkov
authorJames Teh <jteh@mozilla.com>
Wed, 05 Sep 2018 04:43:18 +0000
changeset 493173 0b3bd846add245f60a32ddc787890f411241dfdc
parent 493172 0f6244fb0d66654c0d5469c906e9c9343e18c6bf
child 493174 3115b98972da40b82a88b2f18fdcade313dc081c
push id1864
push userffxbld-merge
push dateMon, 03 Dec 2018 15:51:40 +0000
treeherdermozilla-release@f040763d99ad [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssurkov
bugs1488109
milestone64.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1488109: If the focused element has aria-activedescendant and the target id is moved to another node, move accessible focus to the new target. r=surkov Differential Revision: https://phabricator.services.mozilla.com/D4832
accessible/base/FocusManager.h
accessible/generic/DocAccessible.cpp
accessible/generic/DocAccessible.h
accessible/tests/mochitest/events/test_focus_aria_activedescendant.html
--- a/accessible/base/FocusManager.h
+++ b/accessible/base/FocusManager.h
@@ -39,16 +39,21 @@ public:
   /**
    * Return true if the given accessible is an active item, i.e. an item that
    * is current within the active widget.
    */
   inline bool IsActiveItem(const Accessible* aAccessible)
     { return aAccessible == mActiveItem; }
 
   /**
+   * Return DOM node having DOM focus.
+   */
+  nsINode* FocusedDOMNode() const;
+
+  /**
    * Return true if given DOM node has DOM focus.
    */
   inline bool HasDOMFocus(const nsINode* aNode) const
     { return aNode == FocusedDOMNode(); }
 
   /**
    * Return true if focused accessible is within the given container.
    */
@@ -109,21 +114,16 @@ public:
 protected:
   FocusManager();
 
 private:
   FocusManager(const FocusManager&);
   FocusManager& operator =(const FocusManager&);
 
   /**
-   * Return DOM node having DOM focus.
-   */
-  nsINode* FocusedDOMNode() const;
-
-  /**
    * Return DOM document having DOM focus.
    */
   nsIDocument* FocusedDOMDocument() const;
 
 private:
   RefPtr<Accessible> mActiveItem;
   RefPtr<Accessible> mActiveARIAMenubar;
 };
--- a/accessible/generic/DocAccessible.cpp
+++ b/accessible/generic/DocAccessible.cpp
@@ -916,16 +916,17 @@ DocAccessible::AttributeChangedImpl(Acce
     RefPtr<AccEvent> event =
       new AccStateChangeEvent(aAccessible, states::BUSY, isOn);
     FireDelayedEvent(event);
     return;
   }
 
   if (aAttribute == nsGkAtoms::id) {
     RelocateARIAOwnedIfNeeded(elm);
+    ARIAActiveDescendantIDMaybeMoved(elm);
   }
 
   // ARIA or XUL selection
   if ((aAccessible->GetContent()->IsXULElement() &&
        aAttribute == nsGkAtoms::selected) ||
       aAttribute == nsGkAtoms::aria_selected) {
     Accessible* widget =
       nsAccUtils::GetSelectableContainer(aAccessible, aAccessible->State());
@@ -2473,8 +2474,51 @@ DocAccessible::DispatchScrollingEvent(ui
 
   RefPtr<AccEvent> event = new AccScrollingEvent(aEventType, this,
                                                  scrollPoint.x, scrollPoint.y,
                                                  scrollRange.width,
                                                  scrollRange.height);
 
   nsEventShell::FireEvent(event);
 }
+
+void
+DocAccessible::ARIAActiveDescendantIDMaybeMoved(dom::Element* aElm)
+{
+  nsINode* focusNode = FocusMgr()->FocusedDOMNode();
+  // The focused element must be within this document.
+  if (!focusNode || focusNode->OwnerDoc() != mDocumentNode) {
+    return;
+  }
+
+  dom::Element* focusElm = nullptr;
+  if (focusNode == mDocumentNode) {
+    // The document is focused, so look for aria-activedescendant on the
+    // body/root.
+    focusElm = Elm();
+    if (!focusElm) {
+      return;
+    }
+  } else {
+    MOZ_ASSERT(focusNode->IsElement());
+    focusElm = focusNode->AsElement();
+  }
+
+  // Check if the focus has aria-activedescendant and whether
+  // it refers to the id just set on aElm.
+  nsAutoString id;
+  aElm->GetAttr(kNameSpaceID_None, nsGkAtoms::id, id);
+  if (!focusElm->AttrValueIs(kNameSpaceID_None,
+      nsGkAtoms::aria_activedescendant, id, eCaseMatters)) {
+    return;
+  }
+
+  // The aria-activedescendant target has probably changed.
+  Accessible* acc = GetAccessibleEvenIfNotInMapOrContainer(focusNode);
+  if (!acc) {
+    return;
+  }
+  
+  // The active descendant might have just been inserted and may not be in the
+  // tree yet. Therefore, schedule this async to ensure the tree is up to date.
+  mNotificationController->ScheduleNotification<DocAccessible, Accessible>
+    (this, &DocAccessible::ARIAActiveDescendantChanged, acc);
+}
--- a/accessible/generic/DocAccessible.h
+++ b/accessible/generic/DocAccessible.h
@@ -579,16 +579,26 @@ protected:
    *
    * @param aTimer    [in] the timer object
    * @param aClosure  [in] the document accessible where scrolling happens
    */
   static void ScrollTimerCallback(nsITimer* aTimer, void* aClosure);
 
   void DispatchScrollingEvent(uint32_t aEventType);
 
+  /**
+   * Check if an id attribute change affects aria-activedescendant and handle
+   * the aria-activedescendant change if appropriate.
+   * If the currently focused element has aria-activedescendant and an
+   * element's id changes to match this, the id was probably moved from the
+   * previous active descendant, thus making this element the new active
+   * descendant. In that case, accessible focus must be changed accordingly.
+   */
+  void ARIAActiveDescendantIDMaybeMoved(dom::Element* aElm);
+
 protected:
 
   /**
    * State and property flags, kept by mDocFlags.
    */
   enum {
     // Whether scroll listeners were added.
     eScrollInitialized = 1 << 0,
--- a/accessible/tests/mochitest/events/test_focus_aria_activedescendant.html
+++ b/accessible/tests/mochitest/events/test_focus_aria_activedescendant.html
@@ -45,23 +45,31 @@ https://bugzilla.mozilla.org/show_bug.cg
         getNode(aID).removeAttribute("aria-activedescendant");
       };
 
       this.getID = function clearARIAActiveDescendant_getID() {
         return "clear aria-activedescendant on container " + aID;
       };
     }
 
-    function changeARIAActiveDescendantInvalid(aID) {
+    /**
+     * Change aria-activedescendant to an invalid (non-existent) id.
+     * Ensure that focus is fired on the element itself.
+     */
+    function changeARIAActiveDescendantInvalid(aID, aInvalidID) {
+      if (!aInvalidID) {
+        aInvalidID = "invalid";
+      }
+
       this.eventSeq = [
         new focusChecker(aID)
       ];
 
       this.invoke = function changeARIAActiveDescendant_invoke() {
-        getNode(aID).setAttribute("aria-activedescendant", "invalid");
+        getNode(aID).setAttribute("aria-activedescendant", aInvalidID);
       };
 
       this.getID = function changeARIAActiveDescendant_getID() {
         return "change aria-activedescendant to invalid id";
       };
     }
     
     function insertItemNFocus(aID, aNewItemID) {
@@ -82,34 +90,66 @@ https://bugzilla.mozilla.org/show_bug.cg
         container.setAttribute("aria-activedescendant", aNewItemID);
       };
 
       this.getID = function insertItemNFocus_getID() {
         return "insert new node and focus it with ID: " + aNewItemID;
       };
     }
 
+    /**
+     * Change the id of an element to another id which is the target of
+     * aria-activedescendant.
+     * If another element already has the desired id, remove it from that
+     * element first.
+     * Ensure that focus is fired on the target element which was given the
+     * desired id.
+     * @param aFromID The existing id of the target element.
+     * @param aToID The desired id to be given to the target element.
+    */
+    function moveARIAActiveDescendantID(aFromID, aToID) {
+      this.eventSeq = [
+        new focusChecker(aToID)
+      ];
+
+      this.invoke = function moveARIAActiveDescendantID_invoke() {
+        let orig = document.getElementById(aToID);
+        if (orig) {
+          orig.id = "";
+        }
+        document.getElementById(aFromID).id = aToID;
+      };
+
+      this.getID = function moveARIAActiveDescendantID_getID() {
+        return "move aria-activedescendant id " + aToID;
+      };
+    }
+
     var gQueue = null;
     function doTest() {
       gQueue = new eventQueue();
 
       gQueue.push(new synthFocus("listbox", new focusChecker("item1")));
       gQueue.push(new changeARIAActiveDescendant("listbox", "item2"));
       gQueue.push(new changeARIAActiveDescendant("listbox", "item3"));
 
       gQueue.push(new synthFocus("combobox_entry", new focusChecker("combobox_entry")));
       gQueue.push(new changeARIAActiveDescendant("combobox", "combobox_option2"));
 
       gQueue.push(new synthFocus("listbox", new focusChecker("item3")));
       gQueue.push(new insertItemNFocus("listbox", "item4"));
 
       gQueue.push(new clearARIAActiveDescendant("listbox"));
       gQueue.push(new changeARIAActiveDescendant("listbox", "item1"));
-      gQueue.push(new changeARIAActiveDescendantInvalid("listbox"));
-      gQueue.push(new changeARIAActiveDescendant("listbox", "item1"));
+      gQueue.push(new changeARIAActiveDescendantInvalid("listbox", "invalid"));
+
+      gQueue.push(new changeARIAActiveDescendant("listbox", "roaming"));
+      gQueue.push(new moveARIAActiveDescendantID("roaming2", "roaming"));
+      gQueue.push(new changeARIAActiveDescendantInvalid("listbox", "roaming3"));
+      gQueue.push(new moveARIAActiveDescendantID("roaming", "roaming3"));
 
       gQueue.invoke(); // Will call SimpleTest.finish();
     }
 
     SimpleTest.waitForExplicitFinish();
     addA11yLoadEvent(doTest);
   </script>
 </head>
@@ -129,16 +169,18 @@ https://bugzilla.mozilla.org/show_bug.cg
   <div id="content" style="display: none"></div>
   <pre id="test">
   </pre>
 
   <div role="listbox" aria-activedescendant="item1" id="listbox" tabindex="1"
        aria-owns="item3">
     <div role="listitem" id="item1">item1</div>
     <div role="listitem" id="item2">item2</div>
+    <div role="listitem" id="roaming">roaming</div>
+    <div role="listitem" id="roaming2">roaming2</div>
   </div>
   <div role="listitem" id="item3">item3</div>
 
   <div role="combobox" id="combobox">
     <input id="combobox_entry">
     <ul>
       <li role="option" id="combobox_option1">option1</li>
       <li role="option" id="combobox_option2">option2</li>