Bug 1289170 - improving keyboard accessibility for dev tools tabbar. r=bgrins draft
authorYura Zenevich <yzenevich@mozilla.com>
Wed, 03 Aug 2016 17:05:00 -0400
changeset 396455 0053d258b8abaa34a934289a4d21d3cb7b833efe
parent 396454 de6fd5bb93d26985b6b7eb8b1e2ec896b8e7611a
child 527204 0b34cc4a22f809908fdf0942f2fa21169f4a2ef6
push id25003
push useryura.zenevich@gmail.com
push dateWed, 03 Aug 2016 21:05:28 +0000
reviewersbgrins
bugs1289170
milestone51.0a1
Bug 1289170 - improving keyboard accessibility for dev tools tabbar. r=bgrins MozReview-Commit-ID: 10WSu9nGYmz
devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
devtools/client/framework/toolbox.js
devtools/client/themes/toolbox.css
--- a/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
+++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
@@ -14,23 +14,16 @@ 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, button")].filter(elm =>
@@ -46,43 +39,43 @@ add_task(function* () {
   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]);
+    is(doc.activeElement.id, toolbarControls[i].id, "New control is focused");
     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]);
+    is(doc.activeElement.id, toolbarControls[i].id, "New control is focused");
     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);
+  is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused");
 
   // 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);
+  is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused");
 });
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -146,16 +146,18 @@ function Toolbox(target, selectedTool, h
   this._showDevEditionPromo = this._showDevEditionPromo.bind(this);
   this._updateTextboxMenuItems = this._updateTextboxMenuItems.bind(this);
   this._onBottomHostMinimized = this._onBottomHostMinimized.bind(this);
   this._onBottomHostMaximized = this._onBottomHostMaximized.bind(this);
   this._onToolSelectWhileMinimized = this._onToolSelectWhileMinimized.bind(this);
   this._onPerformanceFrontEvent = this._onPerformanceFrontEvent.bind(this);
   this._onBottomHostWillChange = this._onBottomHostWillChange.bind(this);
   this._toggleMinimizeMode = this._toggleMinimizeMode.bind(this);
+  this._onTabbarFocus = this._onTabbarFocus.bind(this);
+  this._onTabbarArrowKeypress = this._onTabbarArrowKeypress.bind(this);
 
   this._target.on("close", this.destroy);
 
   if (!hostType) {
     hostType = Services.prefs.getCharPref(this._prefs.LAST_HOST);
   }
   if (!selectedTool) {
     selectedTool = Services.prefs.getCharPref(this._prefs.LAST_TOOL);
@@ -426,17 +428,21 @@ Toolbox.prototype = {
       this._applyServiceWorkersTestingSettings();
       this._addKeysToWindow();
       this._addReloadKeys(shortcuts);
       this._addHostListeners(shortcuts);
       this._registerOverlays();
       if (!this._hostOptions || this._hostOptions.zoom === true) {
         ZoomKeys.register(this.win);
       }
-      this._setToolbarKeyboardNavigation();
+
+      this.tabbar = this.doc.querySelector(".devtools-tabbar");
+      this.tabbar.addEventListener("focus", this._onTabbarFocus, true);
+      this.tabbar.addEventListener("click", this._onTabbarFocus, true);
+      this.tabbar.addEventListener("keypress", this._onTabbarArrowKeypress);
 
       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();
@@ -751,16 +757,19 @@ Toolbox.prototype = {
       return;
     }
 
     // Bottom-type host can be minimized, add a button for this.
     if (this.hostType == Toolbox.HostType.BOTTOM) {
       let minimizeBtn = this.doc.createElementNS(HTML_NS, "button");
       minimizeBtn.id = "toolbox-dock-bottom-minimize";
       minimizeBtn.className = "devtools-button";
+      /* Bug 1177463 - The minimize button is currently hidden until we agree on
+         the UI for it, and until bug 1173849 is fixed too. */
+      minimizeBtn.setAttribute("hidden", "true");
 
       minimizeBtn.addEventListener("click", this._toggleMinimizeMode);
       dockBox.appendChild(minimizeBtn);
       // Show the button in its maximized state.
       this._onBottomHostMaximized();
 
       // Update the label and icon when the state changes.
       this._host.on("minimized", this._onBottomHostMinimized);
@@ -838,95 +847,92 @@ Toolbox.prototype = {
 
   _toggleMinimizeMode: function () {
     if (this.hostType !== Toolbox.HostType.BOTTOM) {
       return;
     }
 
     // Calculate the height to which the host should be minimized so the
     // tabbar is still visible.
-    let toolbarHeight = this.doc.querySelector(".devtools-tabbar")
-                                .getBoxQuads({box: "content"})[0]
-                                .bounds.height;
+    let toolbarHeight = this.tabbar.getBoxQuads({box: "content"})[0].bounds
+                                                                    .height;
     this._host.toggleMinimizeMode(toolbarHeight);
   },
 
   /**
    * Add tabs to the toolbox UI for registered tools
    */
   _buildTabs: function () {
     for (let definition of gDevTools.getToolDefinitionArray()) {
       this._buildTabForTool(definition);
     }
   },
 
   /**
-   * Sets up keyboard navigation with and within the dev tools toolbar.
+   * Get all dev tools tab bar focusable elements. These are visible elements
+   * such as buttons or elements with tabindex.
    */
-  _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");
+  get tabbarFocusableElms() {
+    return [...this.tabbar.querySelectorAll(
+      "[tabindex]:not([hidden]), button:not([hidden])")];
+  },
 
-      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);
+  /**
+   * Reset tabindex attributes across all focusable elements inside the tabbar.
+   * Only have one element with tabindex=0 at a time to make sure that tabbing
+   * results in navigating away from the tabbar container.
+   * @param  {FocusEvent} event
+   */
+  _onTabbarFocus: function (event) {
+    this.tabbarFocusableElms.forEach(elm =>
+      elm.setAttribute("tabindex", event.target === elm ? "0" : "-1"));
+  },
 
-    toolbar.addEventListener("keypress", event => {
-      let { key, target } = event;
-      let win = this.win;
-      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.
+  /**
+   * On left/right arrow press, attempt to move the focus inside the tabbar to
+   * the previous/next focusable element.
+   * @param  {KeyboardEvent} event
+   */
+  _onTabbarArrowKeypress: function (event) {
+    let { key, target } = event;
+    let focusableElms = this.tabbarFocusableElms;
+    let curIndex = focusableElms.indexOf(target);
+
+    if (curIndex === -1) {
+      console.warn(target + " is not found among Developer Tools tab bar " +
+        "focusable elements. It needs to either be a button or have " +
+        "tabindex. If it is intended to be hidden, 'hidden' attribute must " +
+        "be used.");
+      return;
+    }
+
+    let newTarget;
+
+    if (key === "ArrowLeft") {
+      // Do nothing if already at the beginning.
+      if (curIndex === 0) {
         return;
       }
-      event.preventDefault();
-      Services.focus.moveFocus(win, elm, type, 0);
-    });
+      newTarget = focusableElms[curIndex - 1];
+    } else if (key === "ArrowRight") {
+      // Do nothing if already at the end.
+      if (curIndex === focusableElms.length - 1) {
+        return;
+      }
+      newTarget = focusableElms[curIndex + 1];
+    } else {
+      return;
+    }
+
+    focusableElms.forEach(elm =>
+      elm.setAttribute("tabindex", newTarget === elm ? "0" : "-1"));
+    newTarget.focus();
+
+    event.preventDefault();
+    event.stopPropagation();
   },
 
   /**
    * Add buttons to the UI as specified in the devtools.toolbox.toolbarSpec pref
    */
   _buildButtons: function () {
     if (!this.target.isAddon || this.target.isWebExtension) {
       this._buildPickerButton();
@@ -1094,16 +1100,17 @@ Toolbox.prototype = {
 
     let radio = this.doc.createElement("radio");
     // The radio element is not being used in the conventional way, thus
     // the devtools-tab class replaces the radio XBL binding with its base
     // binding (the control-item binding).
     radio.className = "devtools-tab";
     radio.id = "toolbox-tab-" + id;
     radio.setAttribute("toolid", id);
+    radio.setAttribute("tabindex", "0");
     radio.setAttribute("ordinal", toolDefinition.ordinal);
     radio.setAttribute("tooltiptext", toolDefinition.tooltip);
     if (toolDefinition.invertIconForLightTheme) {
       radio.setAttribute("icon-invertable", "light-theme");
     } else if (toolDefinition.invertIconForDarkTheme) {
       radio.setAttribute("icon-invertable", "dark-theme");
     }
 
@@ -2044,16 +2051,19 @@ Toolbox.prototype = {
     if (this.webconsolePanel) {
       this._saveSplitConsoleHeight();
       this.webconsolePanel.removeEventListener("resize",
         this._saveSplitConsoleHeight);
     }
     this.closeButton.removeEventListener("click", this.destroy, true);
     this.textboxContextMenuPopup.removeEventListener("popupshowing",
       this._updateTextboxMenuItems, true);
+    this.tabbar.removeEventListener("focus", this._onTabbarFocus, true);
+    this.tabbar.removeEventListener("click", this._onTabbarFocus, true);
+    this.tabbar.removeEventListener("keypress", this._onTabbarArrowKeypress);
 
     let outstanding = [];
     for (let [id, panel] of this._toolPanels) {
       try {
         gDevTools.emit(id + "-destroy", this, panel);
         this.emit(id + "-destroy", panel);
 
         outstanding.push(panel.destroy());
--- a/devtools/client/themes/toolbox.css
+++ b/devtools/client/themes/toolbox.css
@@ -65,17 +65,16 @@
   min-height: 24px;
   max-width: 100px;
   margin: 0;
   padding: 0;
   border-style: solid;
   border-width: 0;
   border-inline-start-width: 1px;
   -moz-box-align: center;
-  -moz-user-focus: normal;
   -moz-box-flex: 1;
 }
 
 /* Save space on the tab-strip in Firebug theme */
 .theme-firebug .devtools-tab {
   -moz-box-flex: initial;
 }
 
@@ -212,17 +211,16 @@
   margin: 0 8px;
 }
 
 /* Toolbox controls */
 
 #toolbox-controls > button,
 #toolbox-dock-buttons > button {
   -moz-appearance: none;
-  -moz-user-focus: normal;
   border: none;
   margin: 0 4px;
   min-width: 16px;
   width: 16px;
 }
 
 /* Save space in Firebug theme */
 .theme-firebug #toolbox-controls button {
@@ -242,22 +240,16 @@
 #toolbox-dock-side::before {
   background-image: var(--dock-side-image);
 }
 
 #toolbox-dock-window::before {
   background-image: var(--dock-undock-image);
 }
 
-#toolbox-dock-bottom-minimize {
-  /* Bug 1177463 - The minimize button is currently hidden until we agree on
-     the UI for it, and until bug 1173849 is fixed too. */
-  display: none;
-}
-
 #toolbox-dock-bottom-minimize::before {
   background-image: url("chrome://devtools/skin/images/dock-bottom-minimize@2x.png");
 }
 
 #toolbox-dock-bottom-minimize.minimized::before {
   background-image: url("chrome://devtools/skin/images/dock-bottom-maximize@2x.png");
 }
 
@@ -271,17 +263,16 @@
 }
 
 /* Command buttons */
 
 .command-button {
   padding: 0;
   margin: 0;
   position: relative;
-  -moz-user-focus: normal;
 }
 
 .command-button::before {
   opacity: 0.7;
 }
 
 .command-button:hover {
   background-color: var(--toolbar-tab-hover);