Bug 1242852 - (part 1) making top dev tools toolbar keyboard accessible. r=bgrins draft
authorYura Zenevich <yzenevich@mozilla.com>
Tue, 12 Apr 2016 11:53:28 -0400
changeset 349901 d26a1dc86672a77b095e212ed658ab3fd89c0af2
parent 349863 49d7fb650c9dde7cf6e4b2c7aa578a4a11e83f83
child 349902 5399de18ce0f5385088e17c247ffe251ccdf7c63
push id15215
push useryura.zenevich@gmail.com
push dateTue, 12 Apr 2016 15:54:25 +0000
reviewersbgrins
bugs1242852, 100644
milestone48.0a1
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 {