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 483079 0b3bd846add245f60a32ddc787890f411241dfdc
parent 483078 0f6244fb0d66654c0d5469c906e9c9343e18c6bf
child 483080 3115b98972da40b82a88b2f18fdcade313dc081c
push id232
push userfmarier@mozilla.com
push dateWed, 05 Sep 2018 20:45:54 +0000
reviewerssurkov
bugs1488109
milestone64.0a1
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>