Bug 1578445 - Add roles and aria attributes to tip elements. r=dao,fluent-reviewers,Gijs,Jamie
☠☠ backed out by db08bedeceb5 ☠ ☠
authorHarry Twyford <htwyford@mozilla.com>
Thu, 17 Oct 2019 10:48:26 +0000
changeset 559323 62d172103a78cb1241563dfdf0bf77b89fd80064
parent 559322 81f43d4b352c10027b4886aae9e83e409e8856b0
child 559324 8aeb2407e5e51ece6f5f57c98cc46fc8cfe4f53a
push id12177
push usercsabou@mozilla.com
push dateMon, 21 Oct 2019 14:52:16 +0000
treeherdermozilla-beta@1918a9cd33bc [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdao, fluent-reviewers, Gijs, Jamie
bugs1578445
milestone71.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 1578445 - Add roles and aria attributes to tip elements. r=dao,fluent-reviewers,Gijs,Jamie Differential Revision: https://phabricator.services.mozilla.com/D47980
accessible/tests/browser/events/browser_test_focus_urlbar.js
browser/components/urlbar/UrlbarView.jsm
browser/locales/en-US/browser/browser.ftl
--- a/accessible/tests/browser/events/browser_test_focus_urlbar.js
+++ b/accessible/tests/browser/events/browser_test_focus_urlbar.js
@@ -4,26 +4,25 @@
 "use strict";
 
 /* import-globals-from ../../mochitest/states.js */
 /* import-globals-from ../../mochitest/role.js */
 loadScripts(
   { name: "states.js", dir: MOCHITESTS_DIR },
   { name: "role.js", dir: MOCHITESTS_DIR }
 );
-ChromeUtils.defineModuleGetter(
-  this,
-  "PlacesTestUtils",
-  "resource://testing-common/PlacesTestUtils.jsm"
-);
-ChromeUtils.defineModuleGetter(
-  this,
-  "PlacesUtils",
-  "resource://gre/modules/PlacesUtils.jsm"
-);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  PlacesTestUtils: "resource://testing-common/PlacesTestUtils.jsm",
+  PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
+  UrlbarProvider: "resource:///modules/UrlbarUtils.jsm",
+  UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm",
+  UrlbarResult: "resource:///modules/UrlbarResult.jsm",
+  UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
+});
 
 function isEventForAutocompleteItem(event) {
   return event.accessible.role == ROLE_COMBOBOX_OPTION;
 }
 
 function isEventForButton(event) {
   return event.accessible.role == ROLE_PUSHBUTTON;
 }
@@ -41,29 +40,69 @@ function isEventForOneOffEngine(event) {
 function isEventForMenuPopup(event) {
   return event.accessible.role == ROLE_MENUPOPUP;
 }
 
 function isEventForMenuItem(event) {
   return event.accessible.role == ROLE_MENUITEM;
 }
 
+function isEventForTipButton(event) {
+  let parent = event.accessible.parent;
+  return (
+    event.accessible.role == ROLE_PUSHBUTTON &&
+    parent &&
+    parent.role == ROLE_GROUPING &&
+    parent.name
+  );
+}
+
 /**
  * Wait for an autocomplete search to finish.
  * This is necessary to ensure predictable results, as these searches are
  * async. Pressing down arrow will use results from the previous input if the
  * search isn't finished yet.
  */
 function waitForSearchFinish() {
   return Promise.all([
     gURLBar.lastQueryContextPromise,
     BrowserTestUtils.waitForCondition(() => gURLBar.view.isOpen),
   ]);
 }
 
+/**
+ * A test provider.
+ */
+class TipTestProvider extends UrlbarProvider {
+  constructor(matches) {
+    super();
+    this._matches = matches;
+  }
+  get name() {
+    return "TipTestProvider";
+  }
+  get type() {
+    return UrlbarUtils.PROVIDER_TYPE.PROFILE;
+  }
+  isActive(context) {
+    return true;
+  }
+  isRestricting(context) {
+    return true;
+  }
+  async startQuery(context, addCallback) {
+    this._context = context;
+    for (const match of this._matches) {
+      addCallback(this, match);
+    }
+  }
+  cancelQuery(context) {}
+  pickResult(result, details) {}
+}
+
 // Check that the URL bar manages accessibility focus appropriately.
 async function runTests() {
   registerCleanupFunction(async function() {
     await PlacesUtils.history.clear();
   });
 
   await PlacesTestUtils.addVisits([
     { uri: makeURI("http://example1.com/blah") },
@@ -242,9 +281,107 @@ async function runTests() {
     isEventForMenuPopup
   );
   EventUtils.synthesizeKey("KEY_Escape");
   await closed;
   await focused;
   testStates(textBox, STATE_FOCUSED);
 }
 
+// We test TIP results in their own test so the spoofed results don't interfere
+// with the main test.
+async function runTipTests() {
+  let matches = [
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.URL,
+      UrlbarUtils.RESULT_SOURCE.HISTORY,
+      { url: "http://mozilla.org/a" }
+    ),
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.TIP,
+      UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
+      {
+        icon: "",
+        text: "This is a test intervention.",
+        buttonText: "Done",
+        data: "test",
+        helpUrl: "about:blank",
+        buttonUrl: "about:mozilla",
+      }
+    ),
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.URL,
+      UrlbarUtils.RESULT_SOURCE.HISTORY,
+      { url: "http://mozilla.org/b" }
+    ),
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.URL,
+      UrlbarUtils.RESULT_SOURCE.HISTORY,
+      { url: "http://mozilla.org/c" }
+    ),
+  ];
+
+  let provider = new TipTestProvider(matches);
+  UrlbarProvidersManager.registerProvider(provider);
+
+  registerCleanupFunction(async function() {
+    UrlbarProvidersManager.unregisterProvider(provider);
+  });
+
+  let focused = waitForEvent(
+    EVENT_FOCUS,
+    event => event.accessible.role == ROLE_ENTRY
+  );
+  gURLBar.focus();
+  let event = await focused;
+  let textBox = event.accessible;
+
+  EventUtils.synthesizeKey("KEY_Escape");
+  EventUtils.synthesizeKey("KEY_Escape");
+
+  info("Ensuring no focus change when first text is typed");
+  EventUtils.sendString("example");
+  await waitForSearchFinish();
+  // Wait a tick for a11y events to fire.
+  await TestUtils.waitForTick();
+  testStates(textBox, STATE_FOCUSED);
+
+  info("Ensuring autocomplete focus on down arrow (1)");
+  focused = waitForEvent(EVENT_FOCUS, isEventForAutocompleteItem);
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  event = await focused;
+  testStates(event.accessible, STATE_FOCUSED);
+
+  info("Ensuring the tip button is focused on down arrow");
+  info("Also ensuring that the tip button is a part of a labelled group");
+  focused = waitForEvent(EVENT_FOCUS, isEventForTipButton);
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  event = await focused;
+  testStates(event.accessible, STATE_FOCUSED);
+
+  info("Ensuring the help button is focused on down arrow");
+  info("Also ensuring that the help button is a part of a labelled group");
+  focused = waitForEvent(EVENT_FOCUS, isEventForTipButton);
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  event = await focused;
+  testStates(event.accessible, STATE_FOCUSED);
+
+  info("Ensuring autocomplete focus on down arrow (2)");
+  focused = waitForEvent(EVENT_FOCUS, isEventForAutocompleteItem);
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  event = await focused;
+  testStates(event.accessible, STATE_FOCUSED);
+
+  info("Ensuring the help button is focused on up arrow");
+  focused = waitForEvent(EVENT_FOCUS, isEventForTipButton);
+  EventUtils.synthesizeKey("KEY_ArrowUp");
+  event = await focused;
+  testStates(event.accessible, STATE_FOCUSED);
+
+  info("Ensuring text box focus on left arrow, and not back to the tip button");
+  focused = waitForEvent(EVENT_FOCUS, textBox);
+  EventUtils.synthesizeKey("KEY_ArrowLeft");
+  await focused;
+  testStates(textBox, STATE_FOCUSED);
+}
+
 addAccessibleTask(``, runTests);
+addAccessibleTask(``, runTipTests);
--- a/browser/components/urlbar/UrlbarView.jsm
+++ b/browser/components/urlbar/UrlbarView.jsm
@@ -15,16 +15,23 @@ XPCOMUtils.defineLazyModuleGetters(this,
   UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm",
   UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
 });
 
 // Stale rows are removed on a timer with this timeout.  Tests can override this
 // by setting UrlbarView.removeStaleRowsTimeout.
 const DEFAULT_REMOVE_STALE_ROWS_TIMEOUT = 400;
 
+// The classNames of view elements that can be selected.
+const SELECTABLE_ELEMENTS = [
+  "urlbarView-row",
+  "urlbarView-tip-button",
+  "urlbarView-tip-help",
+];
+
 /**
  * Receives and displays address bar autocomplete results.
  */
 class UrlbarView {
   /**
    * @param {UrlbarInput} input
    *   The UrlbarInput instance belonging to this UrlbarView instance.
    */
@@ -736,43 +743,53 @@ class UrlbarView {
     let item = this._createElement("div");
     item.className = "urlbarView-row";
     item.setAttribute("role", "option");
     item._elements = new Map();
 
     let content = this._createElement("span");
     content.className = "urlbarView-row-inner";
     item.appendChild(content);
+    item._elements.set("rowInner", content);
 
     let typeIcon = this._createElement("span");
     typeIcon.className = "urlbarView-type-icon";
     content.appendChild(typeIcon);
 
     let favicon = this._createElement("img");
     favicon.className = "urlbarView-favicon";
     content.appendChild(favicon);
     item._elements.set("favicon", favicon);
 
     let title = this._createElement("span");
     title.className = "urlbarView-title";
     content.appendChild(title);
     item._elements.set("title", title);
 
     if (type == UrlbarUtils.RESULT_TYPE.TIP) {
+      // We use role="group" so screen readers will read the group's label
+      // when a button inside it gets focus. (Screen readers don't do this for
+      // role="option".)
+      // we set aria-labelledby for the group in _updateIndices.
+      content.setAttribute("role", "group");
       let buttonSpacer = this._createElement("span");
       buttonSpacer.className = "urlbarView-tip-button-spacer";
       content.appendChild(buttonSpacer);
 
       let tipButton = this._createElement("span");
       tipButton.className = "urlbarView-tip-button";
+      tipButton.setAttribute("role", "button");
       content.appendChild(tipButton);
       item._elements.set("tipButton", tipButton);
 
       let helpIcon = this._createElement("span");
       helpIcon.className = "urlbarView-tip-help";
+      helpIcon.setAttribute("role", "button");
+      helpIcon.setAttribute("data-l10n-id", "urlbar-tip-help-icon");
+      item._elements.set("helpButton", helpIcon);
       content.appendChild(helpIcon);
     } else {
       let tagsContainer = this._createElement("span");
       tagsContainer.className = "urlbarView-tags";
       content.appendChild(tagsContainer);
       item._elements.set("tagsContainer", tagsContainer);
 
       let titleSeparator = this._createElement("span");
@@ -938,16 +955,26 @@ class UrlbarView {
     item._elements.get("titleSeparator").hidden = !action && !setURL;
   }
 
   _updateIndices() {
     for (let i = 0; i < this._rows.children.length; i++) {
       let item = this._rows.children[i];
       item.result.rowIndex = i;
       item.id = "urlbarView-row-" + i;
+      if (item.result.type == UrlbarUtils.RESULT_TYPE.TIP) {
+        let title = item._elements.get("title");
+        title.id = item.id + "-title";
+        let content = item._elements.get("rowInner");
+        content.setAttribute("aria-labelledby", title.id);
+        let tipButton = item._elements.get("tipButton");
+        tipButton.id = item.id + "-tip-button";
+        let helpButton = item._elements.get("helpButton");
+        helpButton.id = item.id + "-tip-help";
+      }
     }
     let selectableElement = this._getFirstSelectableElement();
     let uiIndex = 0;
     while (selectableElement) {
       selectableElement.elementIndex = uiIndex++;
       selectableElement = this._getNextSelectableElement(selectableElement);
     }
   }
@@ -1035,19 +1062,17 @@ class UrlbarView {
    */
   _getFirstSelectableElement() {
     let firstElementChild = this._rows.firstElementChild;
     if (
       firstElementChild &&
       firstElementChild.result &&
       firstElementChild.result.type == UrlbarUtils.RESULT_TYPE.TIP
     ) {
-      firstElementChild = firstElementChild.querySelector(
-        ".urlbarView-tip-button"
-      );
+      firstElementChild = firstElementChild.get("tipButton");
     }
     return firstElementChild;
   }
 
   /**
    * Returns the last selectable element in the view.
    *
    * @returns {Element} The last selectable element in the view.
@@ -1059,76 +1084,72 @@ class UrlbarView {
     while (lastElementChild && !this._isElementVisible(lastElementChild)) {
       lastElementChild = this._getPreviousSelectableElement(lastElementChild);
     }
 
     if (
       lastElementChild.result &&
       lastElementChild.result.type == UrlbarUtils.RESULT_TYPE.TIP
     ) {
-      lastElementChild = lastElementChild.querySelector(".urlbarView-tip-help");
+      lastElementChild = lastElementChild._elements.get("helpButton");
     }
 
     return lastElementChild;
   }
 
   /**
    * Returns the next selectable element after the parameter `element`.
    * @param {Element} element A selectable element in the view.
    * @returns {Element} The next selectable element after the parameter `element`.
    */
   _getNextSelectableElement(element) {
     let next;
     if (element.classList.contains("urlbarView-tip-button")) {
-      next = element
-        .closest(".urlbarView-row")
-        .querySelector(".urlbarView-tip-help");
+      next = element.closest(".urlbarView-row")._elements.get("helpButton");
     } else if (element.classList.contains("urlbarView-tip-help")) {
       next = element.closest(".urlbarView-row").nextElementSibling;
     } else {
       next = element.nextElementSibling;
     }
 
     if (!next) {
       return null;
     }
 
     if (next.result && next.result.type == UrlbarUtils.RESULT_TYPE.TIP) {
-      next = next.querySelector(".urlbarView-tip-button");
+      next = next._elements.get("tipButton");
     }
 
     return next;
   }
 
   /**
    * Returns the previous selectable element before the parameter `element`.
    * @param {Element} element A selectable element in the view.
    * @returns {Element} The previous selectable element before the parameter `element`.
    */
   _getPreviousSelectableElement(element) {
     let previous;
     if (element.classList.contains("urlbarView-tip-button")) {
       previous = element.closest(".urlbarView-row").previousElementSibling;
     } else if (element.classList.contains("urlbarView-tip-help")) {
-      previous = element
-        .closest(".urlbarView-row")
-        .querySelector(".urlbarView-tip-button");
+      previous = element.closest(".urlbarView-row")._elements.get("tipButton");
     } else {
       previous = element.previousElementSibling;
     }
 
     if (!previous) {
       return null;
     }
 
     if (
       previous.result &&
       previous.result.type == UrlbarUtils.RESULT_TYPE.TIP
     ) {
-      previous = previous.querySelector(".urlbarView-tip-help");
+      previous = previous._elements.get("helpButton");
     }
 
     return previous;
   }
 
   /**
    * Returns the currently selected row. Useful when this._selectedElement may be a
    * non-row element, such as a descendant element of RESULT_TYPE.TIP.
@@ -1292,21 +1313,21 @@ class UrlbarView {
     }
   }
 
   _on_mousedown(event) {
     if (event.button == 2) {
       // Ignore right clicks.
       return;
     }
-    let row = event.target;
-    while (!row.classList.contains("urlbarView-row")) {
-      row = row.parentNode;
+    let target = event.target;
+    while (!SELECTABLE_ELEMENTS.includes(target.className)) {
+      target = target.parentNode;
     }
-    this._selectElement(row, { updateInput: false });
+    this._selectElement(target, { updateInput: false });
     this.controller.speculativeConnect(
       this.selectedResult,
       this._queryContext,
       "mousedown"
     );
   }
 
   _on_mouseup(event) {
--- a/browser/locales/en-US/browser/browser.ftl
+++ b/browser/locales/en-US/browser/browser.ftl
@@ -42,16 +42,18 @@ urlbar-plugins-notification-anchor =
 urlbar-web-rtc-share-devices-notification-anchor =
     .tooltiptext = Manage sharing your camera and/or microphone with the site
 urlbar-autoplay-notification-anchor =
     .tooltiptext = Open autoplay panel
 urlbar-persistent-storage-notification-anchor =
     .tooltiptext = Store data in Persistent Storage
 urlbar-addons-notification-anchor =
     .tooltiptext = Open add-on installation message panel
+urlbar-tip-help-icon =
+    .title = Get help
 
 ## Page Action Context Menu
 
 page-action-add-to-urlbar =
     .label = Add to Address Bar
 page-action-manage-extension =
     .label = Manage Extension…
 page-action-remove-from-urlbar =