Bug 1242852 - (part 1) making top dev tools toolbar keyboard accessible. r=bgrins
authorYura Zenevich <yzenevich@mozilla.com>
Tue, 12 Apr 2016 11:53:28 -0400
changeset 330890 09fb56bc976c0a695eda85777f5c40cede079b7d
parent 330889 5d450730e3e29949ae9556bec510c0e009e10262
child 330891 43a78545f93f6eabbf4b5b581bd3790915f0c1a1
push id6048
push userkmoir@mozilla.com
push dateMon, 06 Jun 2016 19:02:08 +0000
treeherdermozilla-beta@46d72a56c57d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins
bugs1242852, 100644
milestone48.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 1242852 - (part 1) making top dev tools toolbar keyboard accessible. r=bgrins MozReview-Commit-ID: MPMzYnbZOM --- devtools/client/framework/test/browser.ini | 2 + .../test/browser_toolbox_keyboard_navigation.js | 88 ++++++++++++++++++++++ devtools/client/framework/toolbox.js | 67 ++++++++++++++++ devtools/client/themes/toolbars.css | 3 + 4 files changed, 160 insertions(+) create mode 100644 devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
devtools/client/framework/test/browser.ini
devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
devtools/client/framework/toolbox.js
devtools/client/themes/toolbars.css
--- a/devtools/client/framework/test/browser.ini
+++ b/devtools/client/framework/test/browser.ini
@@ -36,16 +36,18 @@ support-files =
 [browser_target_remote.js]
 [browser_target_support.js]
 [browser_toolbox_custom_host.js]
 [browser_toolbox_dynamic_registration.js]
 [browser_toolbox_getpanelwhenready.js]
 [browser_toolbox_highlight.js]
 [browser_toolbox_hosts.js]
 [browser_toolbox_hosts_size.js]
+[browser_toolbox_keyboard_navigation.js]
+skip-if = os == "mac" # Full keyboard navigation on OSX only works if Full Keyboard Access setting is set to All Control in System Keyboard Preferences
 [browser_toolbox_minimize.js]
 skip-if = true # Bug 1177463 - Temporarily hide the minimize button
 [browser_toolbox_options.js]
 [browser_toolbox_options_disable_buttons.js]
 [browser_toolbox_options_disable_cache-01.js]
 [browser_toolbox_options_disable_cache-02.js]
 [browser_toolbox_options_disable_js.js]
 [browser_toolbox_options_enable_serviceworkers_testing.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
@@ -0,0 +1,88 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests keyboard navigation of devtools tabbar.
+
+const TEST_URL =
+  "data:text/html;charset=utf8,test page for toolbar keyboard navigation";
+
+function containsFocus(aDoc, aElm) {
+  let elm = aDoc.activeElement;
+  while (elm) {
+    if (elm === aElm) { return true; }
+    elm = elm.parentNode;
+  }
+  return false;
+}
+
+function testFocus(aDoc, aToolbar, aElm) {
+  let id = aElm.id;
+  is(aToolbar.getAttribute("aria-activedescendant"), id,
+    `Active descendant is set to a new control: ${id}`);
+  is(aDoc.activeElement.id, id, "New control is focused");
+}
+
+add_task(function*() {
+  info("Create a test tab and open the toolbox");
+  let toolbox = yield openNewTabAndToolbox(TEST_URL, "webconsole");
+  let doc = toolbox.doc;
+
+  let toolbar = doc.querySelector(".devtools-tabbar");
+  let toolbarControls = [...toolbar.querySelectorAll(
+    ".devtools-tab, toolbarbutton")].filter(elm =>
+      !elm.hidden && doc.defaultView.getComputedStyle(elm).getPropertyValue(
+        "display") !== "none");
+
+  // Put the keyboard focus onto the first toolbar control.
+  toolbarControls[0].focus();
+  ok(containsFocus(doc, toolbar), "Focus is within the toolbar");
+
+  // Move the focus away from toolbar to a next focusable element.
+  EventUtils.synthesizeKey("VK_TAB", {});
+  ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar");
+
+  // Move the focus back to the toolbar.
+  EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+  ok(containsFocus(doc, toolbar), "Focus is within the toolbar again");
+
+  // Move through the toolbar forward using the right arrow key.
+  for (let i = 0; i < toolbarControls.length; ++i) {
+    testFocus(doc, toolbar, toolbarControls[i]);
+    if (i < toolbarControls.length - 1) {
+      EventUtils.synthesizeKey("VK_RIGHT", {});
+    }
+  }
+
+  // Move the focus away from toolbar to a next focusable element.
+  EventUtils.synthesizeKey("VK_TAB", {});
+  ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar");
+
+  // Move the focus back to the toolbar.
+  EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+  ok(containsFocus(doc, toolbar), "Focus is within the toolbar again");
+
+  // Move through the toolbar backward using the left arrow key.
+  for (let i = toolbarControls.length - 1; i >= 0; --i) {
+    testFocus(doc, toolbar, toolbarControls[i]);
+    if (i > 0) { EventUtils.synthesizeKey("VK_LEFT", {}); }
+  }
+
+  // Move focus to the 3rd (non-first) toolbar control.
+  let expectedFocusedControl = toolbarControls[2];
+  EventUtils.synthesizeKey("VK_RIGHT", {});
+  EventUtils.synthesizeKey("VK_RIGHT", {});
+  testFocus(doc, toolbar, expectedFocusedControl);
+
+  // Move the focus away from toolbar to a next focusable element.
+  EventUtils.synthesizeKey("VK_TAB", {});
+  ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar");
+
+  // Move the focus back to the toolbar, ensure we land on the last active
+  // descendant control.
+  EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+  testFocus(doc, toolbar, expectedFocusedControl);
+});
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -408,16 +408,17 @@ Toolbox.prototype = {
       this._addHostListeners();
       this._registerOverlays();
       if (this._hostOptions && this._hostOptions.zoom === false) {
         this._disableZoomKeys();
       } else {
         this._addZoomKeys();
         this._loadInitialZoom();
       }
+      this._setToolbarKeyboardNavigation();
 
       this.webconsolePanel = this.doc.querySelector("#toolbox-panel-webconsole");
       this.webconsolePanel.height = Services.prefs.getIntPref(SPLITCONSOLE_HEIGHT_PREF);
       this.webconsolePanel.addEventListener("resize", this._saveSplitConsoleHeight);
 
       let buttonsPromise = this._buildButtons();
 
       this._pingTelemetry();
@@ -902,16 +903,82 @@ Toolbox.prototype = {
    */
   _buildTabs: function() {
     for (let definition of gDevTools.getToolDefinitionArray()) {
       this._buildTabForTool(definition);
     }
   },
 
   /**
+   * Sets up keyboard navigation with and within the dev tools toolbar.
+   */
+  _setToolbarKeyboardNavigation() {
+    let toolbar = this.doc.querySelector(".devtools-tabbar");
+    // Set and track aria-activedescendant to indicate which control is
+    // currently focused within the toolbar (for accessibility purposes).
+    toolbar.addEventListener("focus", event => {
+      let { target, rangeParent } = event;
+      let control, controlID = toolbar.getAttribute("aria-activedescendant");
+
+      if (controlID) {
+        control = this.doc.getElementById(controlID);
+      }
+      if (rangeParent || !control) {
+        // If range parent is present, the focused is moved within the toolbar,
+        // simply updating aria-activedescendant. Or if aria-activedescendant is
+        // not available, set it to target.
+        toolbar.setAttribute("aria-activedescendant", target.id);
+      } else {
+        // When range parent is not present, we focused into the toolbar, move
+        // focus to current aria-activedescendant.
+        event.preventDefault();
+        control.focus();
+      }
+    }, true)
+
+    toolbar.addEventListener("keypress", event => {
+      let { key, target } = event;
+      let win = this.doc.defaultView;
+      let elm, type;
+      if (key === "Tab") {
+        // Tabbing when toolbar or its contents are focused should move focus to
+        // next/previous focusable element relative to toolbar itself.
+        if (event.shiftKey) {
+          elm = toolbar;
+          type = Services.focus.MOVEFOCUS_BACKWARD;
+        } else {
+          // To move focus to next element following the toolbar, relative
+          // element needs to be the last element in its subtree.
+          let last = toolbar.lastChild;
+          while (last && last.lastChild) {
+            last = last.lastChild;
+          }
+          elm = last;
+          type = Services.focus.MOVEFOCUS_FORWARD;
+        }
+      } else if (key === "ArrowLeft") {
+        // Using left arrow key inside toolbar should move focus to previous
+        // toolbar control.
+        elm = target;
+        type = Services.focus.MOVEFOCUS_BACKWARD;
+      } else if (key === "ArrowRight") {
+        // Using right arrow key inside toolbar should move focus to next
+        // toolbar control.
+        elm = target;
+        type = Services.focus.MOVEFOCUS_FORWARD;
+      } else {
+        // Ignore all other keys.
+        return;
+      }
+      event.preventDefault();
+      Services.focus.moveFocus(win, elm, type, 0);
+    });
+  },
+
+  /**
    * Add buttons to the UI as specified in the devtools.toolbox.toolbarSpec pref
    */
   _buildButtons: function() {
     if (!this.target.isAddon) {
       this._buildPickerButton();
     }
 
     this.setToolboxButtonsVisibility();
--- a/devtools/client/themes/toolbars.css
+++ b/devtools/client/themes/toolbars.css
@@ -600,16 +600,17 @@
 
 /* Toolbox - moved from toolbox.css.
  * Rules that apply to the global toolbox like command buttons,
  * devtools tabs, docking buttons, etc. */
 
 #toolbox-controls > toolbarbutton,
 #toolbox-dock-buttons > toolbarbutton {
   -moz-appearance: none;
+  -moz-user-focus: normal;
   border: none;
   margin: 0 4px;
   min-width: 16px;
   width: 16px;
 }
 
 #toolbox-controls > toolbarbutton > .toolbarbutton-text,
 #toolbox-dock-buttons > toolbarbutton > .toolbarbutton-text,
@@ -686,16 +687,17 @@
 
 .command-button {
   -moz-appearance: none;
   border: none;
   padding: 0 8px;
   margin: 0;
   width: 32px;
   position: relative;
+  -moz-user-focus: normal;
 }
 
 .command-button:hover {
   background-color: hsla(206,37%,4%,.2);
 }
 .command-button:hover:active, .command-button[checked=true]:not(:hover) {
   background-color: hsla(206,37%,4%,.4);
 }
@@ -807,16 +809,17 @@
   min-height: 24px;
   max-width: 100px;
   margin: 0;
   padding: 0;
   border-style: solid;
   border-width: 0;
   -moz-border-start-width: 1px;
   -moz-box-align: center;
+  -moz-user-focus: normal;
 }
 
 .theme-dark .devtools-tab {
   color: var(--theme-body-color-alt);
   border-color: #42484f;
 }
 
 .theme-light .devtools-tab {