Bug 1242852 - (part 2) making inspector toolbar keyboard accessible. draft
authorYura Zenevich <yzenevich@mozilla.com>
Thu, 24 Mar 2016 13:51:44 -0400
changeset 344462 a1a4a665c156fdd65f733c06212aab78169da408
parent 344454 7d19a5916521cddffe4690f81a81666cbb3405cb
child 516959 d949c03ffff144129fd418d85f18bc43c62e5b7d
push id13829
push useryura.zenevich@gmail.com
push dateThu, 24 Mar 2016 17:52:14 +0000
bugs1242852, 100644
milestone48.0a1
Bug 1242852 - (part 2) making inspector toolbar keyboard accessible. MozReview-Commit-ID: 4Kr9uXRS4P6 --- devtools/client/inspector/breadcrumbs.js | 36 +++++++++++++++ devtools/client/inspector/inspector-search.js | 9 +++- devtools/client/inspector/test/browser.ini | 2 + .../browser_inspector_breadcrumbs_keyboard_trap.js | 52 ++++++++++++++++++++++ .../inspector/test/browser_inspector_search-05.js | 4 +- .../test/browser_inspector_search-navigation.js | 8 ++-- .../test/browser_inspector_search_keyboard_trap.js | 48 ++++++++++++++++++++ devtools/client/inspector/test/head.js | 15 +++++++ 8 files changed, 168 insertions(+), 6 deletions(-) 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-05.js
devtools/client/inspector/test/browser_inspector_search-navigation.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,29 @@ 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);
+    }
+  },
+
+  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 +389,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 +433,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
@@ -271,26 +271,33 @@ SelectorAutocompleter.prototype = {
 
   /**
    * Handles keypresses inside the input box.
    */
   _onSearchKeypress: function(event) {
     let query = this.searchBox.value;
     switch(event.keyCode) {
       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();
         }
         break;
 
+      case event.DOM_VK_TAB:
+        // When tab is pressed with focus on searchbox: do not change its value
+        // even if the popup is open, close the popup, do not prevent the
+        // default to avoid a keyboard trap.
+        this.hidePopup();
+        this.emit("processing-done");
+        return;
+
       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 =
               Math.max(0, this.searchPopup.itemCount - 2);
           }
           else {
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -29,16 +29,17 @@ support-files =
   doc_inspector_search-svg.html
   doc_inspector_select-last-selected-01.html
   doc_inspector_select-last-selected-02.html
   head.js
 
 [browser_inspector_breadcrumbs.js]
 [browser_inspector_breadcrumbs_highlight_hover.js]
 [browser_inspector_breadcrumbs_keybinding.js]
+[browser_inspector_breadcrumbs_keyboard_trap.js]
 [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]
@@ -105,14 +106,15 @@ 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_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,52 @@
+/* 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";
+
+const TEST_URL = URL_ROOT + "doc_inspector_breadcrumbs.html";
+
+/**
+ * Test data has the format of:
+ * [
+ *   {Boolean} flag, indicating if breadcrumbs contains focus
+ *   {String}  key event's key
+ *   {?Object} Optional event data such as shiftKey, etc
+ * ]
+ *
+ */
+const TEST_DATA = [
+  // Move the focus away from breadcrumbs to a next focusable element.
+  [ false, "VK_TAB", { } ],
+  // Move the focus back to the breadcrumbs.
+  [ true, "VK_TAB", { shiftKey: true } ],
+  // Move the focus back away from breadcrumbs to a previous focusable element.
+  [ false, "VK_TAB", { shiftKey: true } ],
+  // Move the focus back to the breadcrumbs.
+  [ true, "VK_TAB", { } ]
+];
+
+add_task(function*() {
+  let { inspector } = yield openInspectorForURL(TEST_URL);
+  let doc = inspector.panelDoc;
+
+  info("Selecting the test node");
+  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]");
+  button.click();
+
+  // Ensure a breadcrumb is focused.
+  is(doc.activeElement, button, "Focus is on selected breadcrumb");
+
+  for (let [focused, ...key] of TEST_DATA) {
+    EventUtils.synthesizeKey(...key);
+    if (focused) {
+      is(doc.activeElement, button, "Focus is on selected breadcrumb");
+    } else {
+      yield inspector.once("breadcrumbs-navigation-cancelled");
+      ok(!containsFocus(doc, container), "Focus is outside of breadcrumbs");
+    }
+  }
+});
--- a/devtools/client/inspector/test/browser_inspector_search-05.js
+++ b/devtools/client/inspector/test/browser_inspector_search-05.js
@@ -29,19 +29,19 @@ add_task(function* () {
   info("Enter # to search for all ids");
   let processingDone = once(inspector.searchSuggestions, "processing-done");
   EventUtils.synthesizeKey("#", {}, inspector.panelWin);
   yield processingDone;
 
   info("Wait for search query to complete");
   yield inspector.searchSuggestions._lastQuery;
 
-  info("Press tab to fill the search input with the first suggestion");
+  info("Press enter to fill the search input with the first suggestion");
   processingDone = once(inspector.searchSuggestions, "processing-done");
-  EventUtils.synthesizeKey("VK_TAB", {}, inspector.panelWin);
+  EventUtils.synthesizeKey("VK_RETURN", {}, inspector.panelWin);
   yield processingDone;
 
   info("Press enter and expect a new selection");
   let onSelect = inspector.once("inspector-updated");
   EventUtils.synthesizeKey("VK_RETURN", {}, inspector.panelWin);
   yield onSelect;
 
   yield checkCorrectButton(inspector, "#iframe-1");
--- a/devtools/client/inspector/test/browser_inspector_search-navigation.js
+++ b/devtools/client/inspector/test/browser_inspector_search-navigation.js
@@ -10,36 +10,38 @@
 const KEY_STATES = [
   ["d", "d"],
   ["i", "di"],
   ["v", "div"],
   [".", "div."],
   ["VK_UP", "div.c1"],
   ["VK_DOWN", "div.l1"],
   ["VK_DOWN", "div.l1"],
+  ["VK_UP", "div.l1"],
+  ["VK_TAB", "div.l1"],
   ["VK_BACK_SPACE", "div.l"],
-  ["VK_TAB", "div.l1"],
+  ["VK_RETURN", "div.l1"],
   [" ", "div.l1 "],
   ["VK_UP", "div.l1 div"],
   ["VK_UP", "div.l1 div"],
   [".", "div.l1 div."],
-  ["VK_TAB", "div.l1 div.c1"],
+  ["VK_RETURN", "div.l1 div.c1"],
   ["VK_BACK_SPACE", "div.l1 div.c"],
   ["VK_BACK_SPACE", "div.l1 div."],
   ["VK_BACK_SPACE", "div.l1 div"],
   ["VK_BACK_SPACE", "div.l1 di"],
   ["VK_BACK_SPACE", "div.l1 d"],
   ["VK_BACK_SPACE", "div.l1 "],
   ["VK_UP", "div.l1 div"],
   ["VK_BACK_SPACE", "div.l1 di"],
   ["VK_BACK_SPACE", "div.l1 d"],
   ["VK_BACK_SPACE", "div.l1 "],
   ["VK_UP", "div.l1 div"],
   ["VK_UP", "div.l1 div"],
-  ["VK_TAB", "div.l1 div"],
+  ["VK_RETURN", "div.l1 div"],
   ["VK_BACK_SPACE", "div.l1 di"],
   ["VK_BACK_SPACE", "div.l1 d"],
   ["VK_BACK_SPACE", "div.l1 "],
   ["VK_DOWN", "div.l1 div"],
   ["VK_DOWN", "div.l1 span"],
   ["VK_DOWN", "div.l1 span"],
   ["VK_BACK_SPACE", "div.l1 spa"],
   ["VK_BACK_SPACE", "div.l1 sp"],
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_search_keyboard_trap.js
@@ -0,0 +1,48 @@
+/* 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";
+
+const TEST_URL = URL_ROOT + "doc_inspector_search.html";
+
+/**
+ * Test data has the format of:
+ * [
+ *   {Boolean} flag, indicating if search box contains focus
+ *   {Array}   list of keys that include key code and optional event data
+ *             (shiftKey, etc)
+ * ]
+ *
+ */
+const TEST_DATA = [
+  // Move focus to a next focusable element.
+  [ false, [ "VK_TAB", {} ] ],
+  // Move focus back to searchbox.
+  [ true, [ "VK_TAB", { shiftKey: true } ] ],
+  // Open popup and then tab away to the a next focusable element.
+  [ false, [ "c", {} ], [ "VK_TAB", {} ] ],
+  // Move focus back to searchbox.
+  [ true, [ "VK_TAB", { 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 [focused, ...keys] of TEST_DATA) {
+    for (let key of keys) {
+      let done = !focused ?
+        inspector.searchSuggestions.once("processing-done") : Promise.resolve();
+      EventUtils.synthesizeKey(...key);
+      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
@@ -603,8 +603,23 @@ 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} aDoc document with active element in question
+ * @param  {DOMNode} aElm element tested on focus containment
+ * @return {Boolean}
+ */
+function containsFocus(aDoc, aElm) {
+  let elm = aDoc.activeElement;
+  while (elm) {
+    if (elm === aElm) { return true; }
+    elm = elm.parentNode;
+  }
+  return false;
+}