Bug 1285591 - fix accessibility in devtools autocomplete using suggestion list clone;r=bgrins
authorJulian Descottes <jdescottes@mozilla.com>
Fri, 22 Jul 2016 17:35:03 +0200
changeset 332280 b27aedfe7ed010677b0565adab04f875ad6369b7
parent 332185 7fd2a709bd6cd1714d8e6639dce3fda9f7b7d899
child 332281 7971ab36aaca23ccf9a766465ad62e8eb365c321
push id9858
push userjlund@mozilla.com
push dateMon, 01 Aug 2016 14:37:10 +0000
treeherdermozilla-aurora@203106ef6cb6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins
bugs1285591
milestone50.0a1
Bug 1285591 - fix accessibility in devtools autocomplete using suggestion list clone;r=bgrins Devtools autocomplete popups are hosted in a different document from the input being autocompleted. To allow accessibility tools such as screen readers to still make sense of this widget, a clone of the suggestion list is now inserted in the same document as the input, and the aria-activedescendant attribute is updated on the input accordingly. MozReview-Commit-ID: 8rFjF6nvEyU
devtools/client/inspector/inspector-search.js
devtools/client/shared/autocomplete-popup.js
devtools/client/themes/common.css
devtools/client/webconsole/test/browser_webconsole_bug_585991_autocomplete_popup.js
--- a/devtools/client/inspector/inspector-search.js
+++ b/devtools/client/inspector/inspector-search.js
@@ -436,18 +436,21 @@ SelectorAutocompleter.prototype = {
       items.unshift(item);
       if (++total > MAX_SUGGESTIONS - 1) {
         break;
       }
     }
 
     if (total > 0) {
       let onPopupOpened = this.searchPopup.once("popup-opened");
-      this.searchPopup.setItems(items);
-      this.searchPopup.openPopup(this.searchBox);
+      this.searchPopup.once("popup-closed", () => {
+        this.searchPopup.setItems(items);
+        this.searchPopup.openPopup(this.searchBox);
+      });
+      this.searchPopup.hidePopup();
       return onPopupOpened;
     }
 
     return this.hidePopup();
   },
 
   /**
    * Hide the suggestion popup if necessary.
--- a/devtools/client/shared/autocomplete-popup.js
+++ b/devtools/client/shared/autocomplete-popup.js
@@ -56,17 +56,21 @@ function AutocompletePopup(toolbox, opti
     "devtools-autocomplete-popup",
     "devtools-monospace",
     theme + "-theme");
   // Stop this appearing as an alert to accessibility.
   this._tooltip.panel.setAttribute("role", "presentation");
 
   this._list = this._document.createElementNS(HTML_NS, "ul");
   this._list.setAttribute("flex", "1");
-  this._list.setAttribute("seltype", "single");
+
+  // The list clone will be inserted in the same document as the anchor, and will receive
+  // a copy of the main list innerHTML to allow screen readers to access the list.
+  this._listClone = this._document.createElementNS(HTML_NS, "ul");
+  this._listClone.className = "devtools-autocomplete-list-aria-clone";
 
   if (options.listId) {
     this._list.setAttribute("id", options.listId);
   }
   this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";
 
   this._tooltip.setContent(this._list);
 
@@ -117,16 +121,20 @@ AutocompletePopup.prototype = {
    *        Vertical offset in pixels from the top of the node to the starting
    *        of the popup.
    * @param {Number} index
    *        The position of item to select.
    */
   openPopup: function (anchor, xOffset = 0, yOffset = 0, index) {
     this.__maxLabelLength = -1;
     this._updateSize();
+
+    // Retrieve the anchor's document active element to add accessibility metadata.
+    this._activeElement = anchor.ownerDocument.activeElement;
+
     this._tooltip.show(anchor, {
       x: xOffset,
       y: yOffset,
       position: this.position,
     });
 
     this._tooltip.once("shown", () => {
       if (this.autoSelect) {
@@ -154,16 +162,19 @@ AutocompletePopup.prototype = {
 
   /**
    * Hide the autocomplete popup panel.
    */
   hidePopup: function () {
     this._tooltip.once("hidden", () => {
       this.emit("popup-closed");
     });
+
+    this._clearActiveDescendant();
+    this._activeElement = null;
     this._tooltip.hide();
   },
 
   /**
    * Check if the autocomplete popup is open.
    */
   get isOpen() {
     return this._tooltip && this._tooltip.isVisible();
@@ -182,16 +193,17 @@ AutocompletePopup.prototype = {
 
     this._list.removeEventListener("click", this.onClick, false);
 
     if (this.autoThemeEnabled) {
       gDevTools.off("pref-changed", this._handleThemeChange);
     }
 
     this._list.remove();
+    this._listClone.remove();
     this._tooltip.destroy();
     this._document = null;
     this._list = null;
     this._tooltip = null;
   },
 
   /**
    * Get the autocomplete items array.
@@ -317,16 +329,19 @@ AutocompletePopup.prototype = {
     }
 
     let item = this.items[index];
     if (this.isOpen && item) {
       let element = this.elements.get(item);
 
       element.classList.add("autocomplete-selected");
       this._scrollElementIntoViewIfNeeded(element);
+      this._setActiveDescendant(element.id);
+    } else {
+      this._clearActiveDescendant();
     }
     this._selectedIndex = index;
 
     if (this.isOpen && item && this.onSelectCallback) {
       // Call the user-defined select callback if defined.
       this.onSelectCallback();
     }
   },
@@ -348,16 +363,51 @@ AutocompletePopup.prototype = {
   set selectedItem(item) {
     let index = this.items.indexOf(item);
     if (index !== -1 && this.isOpen) {
       this.selectedIndex = index;
     }
   },
 
   /**
+   * Update the aria-activedescendant attribute on the current active element for
+   * accessibility.
+   *
+   * @param {String} id
+   *        The id (as in DOM id) of the currently selected autocomplete suggestion
+   */
+  _setActiveDescendant: function (id) {
+    if (!this._activeElement) {
+      return;
+    }
+
+    // Make sure the list clone is in the same document as the anchor.
+    let anchorDoc = this._activeElement.ownerDocument;
+    if (!this._listClone.parentNode || this._listClone.ownerDocument !== anchorDoc) {
+      anchorDoc.documentElement.appendChild(this._listClone);
+    }
+
+    // Update the clone content to match the current list content.
+    this._listClone.innerHTML = this._list.innerHTML;
+
+    this._activeElement.setAttribute("aria-activedescendant", id);
+  },
+
+  /**
+   * Clear the aria-activedescendant attribute on the current active element.
+   */
+  _clearActiveDescendant: function () {
+    if (!this._activeElement) {
+      return;
+    }
+
+    this._activeElement.removeAttribute("aria-activedescendant");
+  },
+
+  /**
    * Append an item into the autocomplete list.
    *
    * @param {Object} item
    *        The item you want appended to the list.
    *        The item object can have the following properties:
    *        - label {String} Property which is used as the displayed value.
    *        - preLabel {String} [Optional] The String that will be displayed
    *                   before the label indicating that this is the already
--- a/devtools/client/themes/common.css
+++ b/devtools/client/themes/common.css
@@ -154,16 +154,32 @@
   color: #222;
 }
 
 .devtools-autocomplete-listbox.firebug-theme .autocomplete-item > span,
 .devtools-autocomplete-listbox.light-theme .autocomplete-item > span {
   color: #666;
 }
 
+/* Autocomplete list clone used for accessibility. */
+
+.devtools-autocomplete-list-aria-clone {
+  /* Cannot use display:none or visibility:hidden : screen readers ignore the element. */
+  position: fixed;
+  overflow: hidden;
+  margin: 0;
+  width: 0;
+  height: 0;
+}
+
+.devtools-autocomplete-list-aria-clone li {
+  /* Prevent screen readers from prefacing every item with 'bullet'. */
+  list-style-type: none;
+}
+
 /* links to source code, like displaying `myfile.js:45` */
 
 .devtools-source-link {
   font-family: var(--monospace-font-family);
   color: var(--theme-highlight-blue);
   cursor: pointer;
   white-space: nowrap;
   display: flex;
--- a/devtools/client/webconsole/test/browser_webconsole_bug_585991_autocomplete_popup.js
+++ b/devtools/client/webconsole/test/browser_webconsole_bug_585991_autocomplete_popup.js
@@ -20,80 +20,104 @@ function consoleOpened(HUD) {
 
   let items = [
     {label: "item0", value: "value0"},
     {label: "item1", value: "value1"},
     {label: "item2", value: "value2"},
   ];
 
   let popup = HUD.jsterm.autocompletePopup;
+  let input = HUD.jsterm.inputNode;
 
   ok(!popup.isOpen, "popup is not open");
+  ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant");
 
   popup.once("popup-opened", () => {
     ok(popup.isOpen, "popup is open");
 
     is(popup.itemCount, 0, "no items");
+    ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant");
 
     popup.setItems(items);
 
     is(popup.itemCount, items.length, "items added");
 
     let sameItems = popup.getItems();
     is(sameItems.every(function (item, index) {
       return item === items[index];
     }), true, "getItems returns back the same items");
 
     is(popup.selectedIndex, 2, "Index of the first item from bottom is selected.");
     is(popup.selectedItem, items[2], "First item from bottom is selected");
+    checkActiveDescendant(popup, input);
 
     popup.selectedIndex = 1;
 
     is(popup.selectedIndex, 1, "index 1 is selected");
     is(popup.selectedItem, items[1], "item1 is selected");
+    checkActiveDescendant(popup, input);
 
     popup.selectedItem = items[2];
 
     is(popup.selectedIndex, 2, "index 2 is selected");
     is(popup.selectedItem, items[2], "item2 is selected");
+    checkActiveDescendant(popup, input);
 
     is(popup.selectPreviousItem(), items[1], "selectPreviousItem() works");
 
     is(popup.selectedIndex, 1, "index 1 is selected");
     is(popup.selectedItem, items[1], "item1 is selected");
+    checkActiveDescendant(popup, input);
 
     is(popup.selectNextItem(), items[2], "selectNextItem() works");
 
     is(popup.selectedIndex, 2, "index 2 is selected");
     is(popup.selectedItem, items[2], "item2 is selected");
+    checkActiveDescendant(popup, input);
 
     ok(popup.selectNextItem(), "selectNextItem() works");
 
     is(popup.selectedIndex, 0, "index 0 is selected");
     is(popup.selectedItem, items[0], "item0 is selected");
+    checkActiveDescendant(popup, input);
 
     items.push({label: "label3", value: "value3"});
     popup.appendItem(items[3]);
 
     is(popup.itemCount, items.length, "item3 appended");
 
     popup.selectedIndex = 3;
     is(popup.selectedItem, items[3], "item3 is selected");
+    checkActiveDescendant(popup, input);
 
     popup.removeItem(items[2]);
 
     is(popup.selectedIndex, 2, "index2 is selected");
     is(popup.selectedItem, items[3], "item3 is still selected");
+    checkActiveDescendant(popup, input);
     is(popup.itemCount, items.length - 1, "item2 removed");
 
     popup.clearItems();
     is(popup.itemCount, 0, "items cleared");
+    ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant");
 
     popup.once("popup-closed", () => {
       deferred.resolve();
     });
     popup.hidePopup();
   });
 
-  popup.openPopup(HUD.jsterm.inputNode);
+  popup.openPopup(input);
 
   return deferred.promise;
 }
+
+function checkActiveDescendant(popup, input) {
+  let activeElement = input.ownerDocument.activeElement;
+  let descendantId = activeElement.getAttribute("aria-activedescendant");
+  let popupItem = popup._tooltip.panel.querySelector("#" + descendantId);
+  let cloneItem = input.ownerDocument.querySelector("#" + descendantId);
+
+  ok(popupItem, "Active descendant is found in the popup list");
+  ok(cloneItem, "Active descendant is found in the list clone");
+  is(popupItem.innerHTML, cloneItem.innerHTML,
+    "Cloned item has the same HTML as the original element");
+}