Bug 1242852 - (part 2) making inspector toolbar keyboard accessible. r=gl
☠☠ backed out by 0a7b395dbfca ☠ ☠
authorYura Zenevich <yzenevich@mozilla.com>
Fri, 08 Apr 2016 10:47:55 -0400
changeset 316261 16747842afff7736323440f7c27e4901525c4aef
parent 316260 61e35a492b0dcba877d68690fe91f584c3383f08
child 316262 67fa8b34b8e6c1a111f901b3ce3188e3d7a9858d
push id9480
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 17:12:58 +0000
treeherdermozilla-aurora@0d6a91c76a9e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgl
bugs1242852, 100644
milestone48.0a1
Bug 1242852 - (part 2) making inspector toolbar keyboard accessible. r=gl MozReview-Commit-ID: BmLtydkQao7 --- devtools/client/inspector/breadcrumbs.js | 41 ++++++++++ devtools/client/inspector/inspector-search.js | 6 ++ devtools/client/inspector/test/browser.ini | 3 + .../browser_inspector_breadcrumbs_keyboard_trap.js | 76 ++++++++++++++++++ .../test/browser_inspector_search_keyboard_trap.js | 93 ++++++++++++++++++++++ devtools/client/inspector/test/head.js | 17 ++++ 6 files changed, 236 insertions(+) create mode 100644 devtools/client/inspector/test/browser_inspector_breadcrumbs_keyboard_trap.js create mode 100644 devtools/client/inspector/test/browser_inspector_search_keyboard_trap.js
devtools/client/inspector/breadcrumbs.js
devtools/client/inspector/inspector-search.js
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_breadcrumbs_keyboard_trap.js
devtools/client/inspector/test/browser_inspector_search_keyboard_trap.js
devtools/client/inspector/test/head.js
--- a/devtools/client/inspector/breadcrumbs.js
+++ b/devtools/client/inspector/breadcrumbs.js
@@ -5,16 +5,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {Cu, Ci} = require("chrome");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 const Services = require("Services");
 const promise = require("promise");
+const FocusManager = Services.focus;
 
 const ENSURE_SELECTION_VISIBLE_DELAY = 50; // ms
 const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data;
 const MAX_LABEL_LENGTH = 40;
 const LOW_PRIORITY_ELEMENTS = {
   "HEAD": true,
   "BASE": true,
   "BASEFONT": true,
@@ -67,16 +68,17 @@ HTMLBreadcrumbs.prototype = {
                       "<box id='breadcrumb-separator-after'></box>" +
                       "<box id='breadcrumb-separator-normal'></box>";
     this.container.parentNode.appendChild(this.separators);
 
     this.container.addEventListener("mousedown", this, true);
     this.container.addEventListener("keypress", this, true);
     this.container.addEventListener("mouseover", this, true);
     this.container.addEventListener("mouseleave", this, true);
+    this.container.addEventListener("focus", this, true);
 
     // We will save a list of already displayed nodes in this array.
     this.nodeHierarchy = [];
 
     // Last selected node in nodeHierarchy.
     this.currentIndex = -1;
 
     // By default, hide the arrows. We let the <scrollbox> show them
@@ -285,16 +287,34 @@ HTMLBreadcrumbs.prototype = {
     if (event.type == "mousedown" && event.button == 0) {
       this.handleMouseDown(event);
     } else if (event.type == "keypress" && this.selection.isElementNode()) {
       this.handleKeyPress(event);
     } else if (event.type == "mouseover") {
       this.handleMouseOver(event);
     } else if (event.type == "mouseleave") {
       this.handleMouseLeave(event);
+    } else if (event.type == "focus") {
+      this.handleFocus(event);
+    }
+  },
+
+  /**
+   * Focus event handler. When breadcrumbs container gets focus, if there is an
+   * already selected breadcrumb, move focus to it.
+   * @param {DOMEvent} event.
+   */
+  handleFocus: function(event) {
+    let control = this.container.querySelector(
+      ".breadcrumbs-widget-item[checked]");
+    if (control && control !== event.target) {
+      // If we already have a selected breadcrumb and focus target is not it,
+      // move focus to selected breadcrumb.
+      event.preventDefault();
+      control.focus();
     }
   },
 
   /**
    * On click and hold, open the siblings menu.
    * @param {DOMEvent} event.
    */
   handleMouseDown: function(event) {
@@ -374,16 +394,36 @@ HTMLBreadcrumbs.prototype = {
             whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
           });
           break;
         case this.chromeWin.KeyEvent.DOM_VK_DOWN:
           navigate = this.walker.nextSibling(this.selection.nodeFront, {
             whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
           });
           break;
+        case this.chromeWin.KeyEvent.DOM_VK_TAB:
+          // Tabbing when breadcrumbs or its contents are focused should move
+          // focus to next/previous focusable element relative to breadcrumbs
+          // themselves.
+          let elm, type;
+          if (event.shiftKey) {
+            elm = this.container;
+            type = FocusManager.MOVEFOCUS_BACKWARD;
+          } else {
+            // To move focus to next element following the breadcrumbs, relative
+            // element needs to be the last element in breadcrumbs' subtree.
+            let last = this.container.lastChild;
+            while (last && last.lastChild) {
+              last = last.lastChild;
+            }
+            elm = last;
+            type = FocusManager.MOVEFOCUS_FORWARD;
+          }
+          FocusManager.moveFocus(this.chromeWin, elm, type, 0);
+          break;
       }
 
       return navigate.then(node => this.navigateTo(node));
     });
 
     event.preventDefault();
     event.stopPropagation();
   },
@@ -398,16 +438,17 @@ HTMLBreadcrumbs.prototype = {
     this.inspector.off("markupmutation", this.update);
 
     this.container.removeEventListener("underflow", this.onscrollboxreflow, false);
     this.container.removeEventListener("overflow", this.onscrollboxreflow, false);
     this.container.removeEventListener("mousedown", this, true);
     this.container.removeEventListener("keypress", this, true);
     this.container.removeEventListener("mouseover", this, true);
     this.container.removeEventListener("mouseleave", this, true);
+    this.container.removeEventListener("focus", this, true);
 
     this.empty();
     this.separators.remove();
 
     this.onscrollboxreflow = null;
     this.container = null;
     this.separators = null;
     this.nodeHierarchy = null;
--- a/devtools/client/inspector/inspector-search.js
+++ b/devtools/client/inspector/inspector-search.js
@@ -283,16 +283,22 @@ SelectorAutocompleter.prototype = {
       case event.DOM_VK_RETURN:
       case event.DOM_VK_TAB:
         if (this.searchPopup.isOpen &&
             this.searchPopup.getItemAtIndex(this.searchPopup.itemCount - 1)
                 .preLabel == query) {
           this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1;
           this.searchBox.value = this.searchPopup.selectedItem.label;
           this.hidePopup();
+        } else if (!this.searchPopup.isOpen && event.keyCode === event.DOM_VK_TAB) {
+          // When tab is pressed with focus on searchbox and closed popup,
+          // do not prevent the default to avoid a keyboard trap and move focus
+          // to next/previous element.
+          this.emit("processing-done");
+          return;
         }
         break;
 
       case event.DOM_VK_UP:
         if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) {
           this.searchPopup.focus();
           if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) {
             this.searchPopup.selectedIndex =
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -38,16 +38,18 @@ support-files =
   !/devtools/client/shared/test/test-actor-registry.js
 
 [browser_inspector_addNode_01.js]
 [browser_inspector_addNode_02.js]
 [browser_inspector_addNode_03.js]
 [browser_inspector_breadcrumbs.js]
 [browser_inspector_breadcrumbs_highlight_hover.js]
 [browser_inspector_breadcrumbs_keybinding.js]
+[browser_inspector_breadcrumbs_keyboard_trap.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_inspector_breadcrumbs_menu.js]
 [browser_inspector_breadcrumbs_mutations.js]
 [browser_inspector_delete-selected-node-01.js]
 [browser_inspector_delete-selected-node-02.js]
 [browser_inspector_delete-selected-node-03.js]
 [browser_inspector_destroy-after-navigation.js]
 [browser_inspector_destroy-before-ready.js]
 [browser_inspector_expand-collapse.js]
@@ -115,15 +117,16 @@ skip-if = (e10s && debug) # Bug 1250058 
 [browser_inspector_remove-iframe-during-load.js]
 [browser_inspector_search-01.js]
 [browser_inspector_search-02.js]
 [browser_inspector_search-03.js]
 [browser_inspector_search-04.js]
 [browser_inspector_search-05.js]
 [browser_inspector_search-06.js]
 [browser_inspector_search-07.js]
+[browser_inspector_search_keyboard_trap.js]
 [browser_inspector_search-reserved.js]
 [browser_inspector_search-selection.js]
 [browser_inspector_select-docshell.js]
 [browser_inspector_select-last-selected.js]
 [browser_inspector_search-navigation.js]
 [browser_inspector_sidebarstate.js]
 [browser_inspector_switch-to-inspector-on-pick.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_breadcrumbs_keyboard_trap.js
@@ -0,0 +1,76 @@
+/* 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";
+
+// Test ability to tab to and away from breadcrumbs using keyboard.
+
+const TEST_URL = URL_ROOT + "doc_inspector_breadcrumbs.html";
+
+/**
+ * Test data has the format of:
+ * {
+ *   desc     {String}   description for better logging
+ *   focused  {Boolean}  flag, indicating if breadcrumbs contain focus
+ *   key      {String}   key event's key
+ *   options  {?Object}  optional event data such as shiftKey, etc
+ * }
+ */
+const TEST_DATA = [
+  {
+    desc: "Move the focus away from breadcrumbs to a next focusable element",
+    focused: false,
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Move the focus back to the breadcrumbs",
+    focused: true,
+    key: "VK_TAB",
+    options: { shiftKey: true }
+  },
+  {
+    desc: "Move the focus back away from breadcrumbs to a previous focusable element",
+    focused: false,
+    key: "VK_TAB",
+    options: { shiftKey: true }
+  },
+  {
+    desc: "Move the focus back to the breadcrumbs",
+    focused: true,
+    key: "VK_TAB",
+    options: { }
+  }
+];
+
+add_task(function*() {
+  let { inspector } = yield openInspectorForURL(TEST_URL);
+  let doc = inspector.panelDoc;
+
+  yield selectNode("#i2", inspector);
+
+  info("Clicking on the corresponding breadcrumbs node to focus it");
+  let container = doc.getElementById("inspector-breadcrumbs");
+  let button = container.querySelector("button[checked]");
+  let onUpdated = inspector.once("breadcrumbs-updated");
+  button.click();
+  yield onUpdated;
+
+  // Ensure a breadcrumb is focused.
+  is(doc.activeElement, button, "Focus is on selected breadcrumb");
+
+  for (let { desc, focused, key, options } of TEST_DATA) {
+    info(desc);
+    if (!focused) {
+      onUpdated = inspector.once("breadcrumbs-navigation-cancelled");
+    }
+    EventUtils.synthesizeKey(key, options);
+    if (focused) {
+      is(doc.activeElement, button, "Focus is on selected breadcrumb");
+    } else {
+      yield onUpdated;
+      ok(!containsFocus(doc, container), "Focus is outside of breadcrumbs");
+    }
+  }
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_search_keyboard_trap.js
@@ -0,0 +1,93 @@
+/* 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";
+
+// Test ability to tab to and away from inspector search using keyboard.
+
+const TEST_URL = URL_ROOT + "doc_inspector_search.html";
+
+/**
+ * Test data has the format of:
+ * {
+ *   desc         {String}    description for better logging
+ *   focused      {Boolean}   flag, indicating if search box contains focus
+ *   keys:        {Array}     list of keys that include key code and optional
+ *                            event data (shiftKey, etc)
+ * }
+ *
+ */
+const TEST_DATA = [
+  {
+    desc: "Move focus to a next focusable element",
+    focused: false,
+    keys: [
+      {
+        key: "VK_TAB",
+        options: { }
+      }
+    ]
+  },
+  {
+    desc: "Move focus back to searchbox",
+    focused: true,
+    keys: [
+      {
+        key: "VK_TAB",
+        options: { shiftKey: true }
+      }
+    ]
+  },
+  {
+    desc: "Open popup and then tab away (2 times) to the a next focusable element",
+    focused: false,
+    keys: [
+      {
+        key: "d",
+        options: { }
+      },
+      {
+        key: "VK_TAB",
+        options: { }
+      },
+      {
+        key: "VK_TAB",
+        options: { }
+      }
+    ]
+  },
+  {
+    desc: "Move focus back to searchbox",
+    focused: true,
+    keys: [
+      {
+        key: "VK_TAB",
+        options: { shiftKey: true }
+      }
+    ]
+  }
+];
+
+add_task(function*() {
+  let { inspector } = yield openInspectorForURL(TEST_URL);
+  let { searchBox } = inspector;
+  let doc = inspector.panelDoc;
+
+  yield selectNode("#b1", inspector);
+  yield focusSearchBoxUsingShortcut(inspector.panelWin);
+
+  // Ensure a searchbox is focused.
+  ok(containsFocus(doc, searchBox), "Focus is in a searchbox");
+
+  for (let { desc, focused, keys } of TEST_DATA) {
+    info(desc);
+    for (let { key, options } of keys) {
+      let done = !focused ?
+        inspector.searchSuggestions.once("processing-done") : Promise.resolve();
+      EventUtils.synthesizeKey(key, options);
+      yield done;
+    }
+    is(containsFocus(doc, searchBox), focused, "Focus is set correctly");
+  }
+});
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -650,8 +650,25 @@ function waitForStyleEditor(toolbox, hre
  * @return a promise that resolves when the expected string has been found or
  * the validator function has returned true, rejects otherwise.
  */
 function waitForClipboard(setup, expected) {
   let def = promise.defer();
   SimpleTest.waitForClipboard(expected, setup, def.resolve, def.reject);
   return def.promise;
 }
+
+/**
+ * Checks if document's active element is within the given element.
+ * @param  {HTMLDocument}  doc document with active element in question
+ * @param  {DOMNode}       container element tested on focus containment
+ * @return {Boolean}
+ */
+function containsFocus(doc, container) {
+  let elm = doc.activeElement;
+  while (elm) {
+    if (elm === container) {
+      return true;
+    }
+    elm = elm.parentNode;
+  }
+  return false;
+}