Bug 1242852 - (part 2) making inspector toolbar keyboard accessible. r=gl
authorYura Zenevich <yzenevich@mozilla.com>
Tue, 12 Apr 2016 11:53:54 -0400
changeset 330891 43a78545f93f6eabbf4b5b581bd3790915f0c1a1
parent 330890 09fb56bc976c0a695eda85777f5c40cede079b7d
child 330892 9d821f105ae9aa7e168dffc08fe33b15f7215b18
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)
reviewersgl
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 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 | 79 ++++++++++++++++++ .../test/browser_inspector_search_keyboard_trap.js | 93 ++++++++++++++++++++++ devtools/client/inspector/test/head.js | 17 ++++ 6 files changed, 239 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]
@@ -116,15 +118,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,79 @@
+/* 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 { toolbox, 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 onHighlight = toolbox.once("node-highlight");
+  button.click();
+  yield onHighlight;
+
+  // 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);
+
+    let onUpdated;
+    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;
+}