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
This commit is contained in:
Julian Descottes
2016-07-22 17:35:03 +02:00
parent 7b99658449
commit e49ad837f4
4 changed files with 97 additions and 4 deletions

View File

@@ -441,8 +441,11 @@ SelectorAutocompleter.prototype = {
if (total > 0) { if (total > 0) {
let onPopupOpened = this.searchPopup.once("popup-opened"); let onPopupOpened = this.searchPopup.once("popup-opened");
this.searchPopup.setItems(items); this.searchPopup.once("popup-closed", () => {
this.searchPopup.openPopup(this.searchBox); this.searchPopup.setItems(items);
this.searchPopup.openPopup(this.searchBox);
});
this.searchPopup.hidePopup();
return onPopupOpened; return onPopupOpened;
} }

View File

@@ -61,7 +61,11 @@ function AutocompletePopup(toolbox, options = {}) {
this._list = this._document.createElementNS(HTML_NS, "ul"); this._list = this._document.createElementNS(HTML_NS, "ul");
this._list.setAttribute("flex", "1"); 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) { if (options.listId) {
this._list.setAttribute("id", options.listId); this._list.setAttribute("id", options.listId);
@@ -122,6 +126,10 @@ AutocompletePopup.prototype = {
openPopup: function (anchor, xOffset = 0, yOffset = 0, index) { openPopup: function (anchor, xOffset = 0, yOffset = 0, index) {
this.__maxLabelLength = -1; this.__maxLabelLength = -1;
this._updateSize(); this._updateSize();
// Retrieve the anchor's document active element to add accessibility metadata.
this._activeElement = anchor.ownerDocument.activeElement;
this._tooltip.show(anchor, { this._tooltip.show(anchor, {
x: xOffset, x: xOffset,
y: yOffset, y: yOffset,
@@ -159,6 +167,9 @@ AutocompletePopup.prototype = {
this._tooltip.once("hidden", () => { this._tooltip.once("hidden", () => {
this.emit("popup-closed"); this.emit("popup-closed");
}); });
this._clearActiveDescendant();
this._activeElement = null;
this._tooltip.hide(); this._tooltip.hide();
}, },
@@ -187,6 +198,7 @@ AutocompletePopup.prototype = {
} }
this._list.remove(); this._list.remove();
this._listClone.remove();
this._tooltip.destroy(); this._tooltip.destroy();
this._document = null; this._document = null;
this._list = null; this._list = null;
@@ -322,6 +334,9 @@ AutocompletePopup.prototype = {
element.classList.add("autocomplete-selected"); element.classList.add("autocomplete-selected");
this._scrollElementIntoViewIfNeeded(element); this._scrollElementIntoViewIfNeeded(element);
this._setActiveDescendant(element.id);
} else {
this._clearActiveDescendant();
} }
this._selectedIndex = index; this._selectedIndex = index;
@@ -352,6 +367,41 @@ AutocompletePopup.prototype = {
} }
}, },
/**
* 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. * Append an item into the autocomplete list.
* *

View File

@@ -159,6 +159,22 @@
color: #666; 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` */ /* links to source code, like displaying `myfile.js:45` */
.devtools-source-link { .devtools-source-link {

View File

@@ -25,13 +25,16 @@ function consoleOpened(HUD) {
]; ];
let popup = HUD.jsterm.autocompletePopup; let popup = HUD.jsterm.autocompletePopup;
let input = HUD.jsterm.inputNode;
ok(!popup.isOpen, "popup is not open"); ok(!popup.isOpen, "popup is not open");
ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant");
popup.once("popup-opened", () => { popup.once("popup-opened", () => {
ok(popup.isOpen, "popup is open"); ok(popup.isOpen, "popup is open");
is(popup.itemCount, 0, "no items"); is(popup.itemCount, 0, "no items");
ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant");
popup.setItems(items); popup.setItems(items);
@@ -44,31 +47,37 @@ function consoleOpened(HUD) {
is(popup.selectedIndex, 2, "Index of the first item from bottom is selected."); is(popup.selectedIndex, 2, "Index of the first item from bottom is selected.");
is(popup.selectedItem, items[2], "First item from bottom is selected"); is(popup.selectedItem, items[2], "First item from bottom is selected");
checkActiveDescendant(popup, input);
popup.selectedIndex = 1; popup.selectedIndex = 1;
is(popup.selectedIndex, 1, "index 1 is selected"); is(popup.selectedIndex, 1, "index 1 is selected");
is(popup.selectedItem, items[1], "item1 is selected"); is(popup.selectedItem, items[1], "item1 is selected");
checkActiveDescendant(popup, input);
popup.selectedItem = items[2]; popup.selectedItem = items[2];
is(popup.selectedIndex, 2, "index 2 is selected"); is(popup.selectedIndex, 2, "index 2 is selected");
is(popup.selectedItem, items[2], "item2 is selected"); is(popup.selectedItem, items[2], "item2 is selected");
checkActiveDescendant(popup, input);
is(popup.selectPreviousItem(), items[1], "selectPreviousItem() works"); is(popup.selectPreviousItem(), items[1], "selectPreviousItem() works");
is(popup.selectedIndex, 1, "index 1 is selected"); is(popup.selectedIndex, 1, "index 1 is selected");
is(popup.selectedItem, items[1], "item1 is selected"); is(popup.selectedItem, items[1], "item1 is selected");
checkActiveDescendant(popup, input);
is(popup.selectNextItem(), items[2], "selectNextItem() works"); is(popup.selectNextItem(), items[2], "selectNextItem() works");
is(popup.selectedIndex, 2, "index 2 is selected"); is(popup.selectedIndex, 2, "index 2 is selected");
is(popup.selectedItem, items[2], "item2 is selected"); is(popup.selectedItem, items[2], "item2 is selected");
checkActiveDescendant(popup, input);
ok(popup.selectNextItem(), "selectNextItem() works"); ok(popup.selectNextItem(), "selectNextItem() works");
is(popup.selectedIndex, 0, "index 0 is selected"); is(popup.selectedIndex, 0, "index 0 is selected");
is(popup.selectedItem, items[0], "item0 is selected"); is(popup.selectedItem, items[0], "item0 is selected");
checkActiveDescendant(popup, input);
items.push({label: "label3", value: "value3"}); items.push({label: "label3", value: "value3"});
popup.appendItem(items[3]); popup.appendItem(items[3]);
@@ -77,15 +86,18 @@ function consoleOpened(HUD) {
popup.selectedIndex = 3; popup.selectedIndex = 3;
is(popup.selectedItem, items[3], "item3 is selected"); is(popup.selectedItem, items[3], "item3 is selected");
checkActiveDescendant(popup, input);
popup.removeItem(items[2]); popup.removeItem(items[2]);
is(popup.selectedIndex, 2, "index2 is selected"); is(popup.selectedIndex, 2, "index2 is selected");
is(popup.selectedItem, items[3], "item3 is still selected"); is(popup.selectedItem, items[3], "item3 is still selected");
checkActiveDescendant(popup, input);
is(popup.itemCount, items.length - 1, "item2 removed"); is(popup.itemCount, items.length - 1, "item2 removed");
popup.clearItems(); popup.clearItems();
is(popup.itemCount, 0, "items cleared"); is(popup.itemCount, 0, "items cleared");
ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant");
popup.once("popup-closed", () => { popup.once("popup-closed", () => {
deferred.resolve(); deferred.resolve();
@@ -93,7 +105,19 @@ function consoleOpened(HUD) {
popup.hidePopup(); popup.hidePopup();
}); });
popup.openPopup(HUD.jsterm.inputNode); popup.openPopup(input);
return deferred.promise; 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");
}