Bug 1436086: Implement keyboard navigation for the main and Bookmarks toolbars. r=Gijs
authorJames Teh <jteh@mozilla.com>
Wed, 13 Feb 2019 22:51:06 +0000
changeset 458986 76e2c2df0ce01be4e542e40afbe2412f850c2c61
parent 458985 b075ee1b25dcd9609e9f5441ce51711769e3d6ad
child 458987 43baecd33ce64f6c09114f6463da2ceffb03bebe
push id78142
push userjteh@mozilla.com
push dateWed, 13 Feb 2019 22:54:26 +0000
treeherderautoland@76e2c2df0ce0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1436086
milestone67.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 1436086: Implement keyboard navigation for the main and Bookmarks toolbars. r=Gijs Having separate tab stops for every toolbar control results in an unmanageable number of tab stops. Therefore, we group several buttons under a single tab stop and allow movement between them using left/right arrows. However, text inputs use the arrow keys for their own purposes, so they need their own tab stop. There are also groups of buttons before and after the URL bar input which should get their own tab stop. The subsequent buttons on the toolbar are then another tab stop after that. Tab stops for groups of buttons are set using the <toolbartabstop/> element. This element is invisible, but gets included in the tab order. When one of these gets focus, it redirects focus to the appropriate button. This avoids the need to continually manage the tabindex of toolbar buttons in response to toolbarchanges. Navigation to for the View site information button and notification anchors is now managed by this new framework. As such, they no longer need their own position in the tab order and the CSS has been tweaked accordingly. For now, this new functionality is behind a pref (browser.toolbars.keyboard_navigation) which is currently disabled by default. Differential Revision: https://phabricator.services.mozilla.com/D15060
browser/app/profile/firefox.js
browser/base/content/browser-toolbarKeyNav.js
browser/base/content/browser.css
browser/base/content/browser.js
browser/base/content/browser.xul
browser/base/content/test/keyboard/browser.ini
browser/base/content/test/keyboard/browser_toolbarButtonKeyPress.js
browser/base/content/test/keyboard/browser_toolbarKeyNav.js
browser/base/jar.mn
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1809,8 +1809,10 @@ pref("browser.discovery.sites", "addons.
 pref("browser.engagement.recent_visited_origins.expiry", 86400); // 24 * 60 * 60 (24 hours in seconds)
 
 pref("browser.aboutConfig.showWarning", true);
 
 #if defined(XP_WIN) && defined(MOZ_LAUNCHER_PROCESS)
 // Launcher process is disabled by default, will be selectively enabled via SHIELD
 pref("browser.launcherProcess.enabled", false);
 #endif // defined(XP_WIN) && defined(MOZ_LAUNCHER_PROCESS)
+
+pref("browser.toolbars.keyboard_navigation", false);
new file mode 100644
--- /dev/null
+++ b/browser/base/content/browser-toolbarKeyNav.js
@@ -0,0 +1,246 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Handle keyboard navigation for toolbars.
+ * Having separate tab stops for every toolbar control results in an
+ * unmanageable number of tab stops. Therefore, we group buttons under a single
+ * tab stop and allow movement between them using left/right arrows.
+ * However, text inputs use the arrow keys for their own purposes, so they need
+ * their own tab stop. There are also groups of buttons before and after the
+ * URL bar input which should get their own tab stop. The subsequent buttons on
+ * the toolbar are then another tab stop after that.
+ * Tab stops for groups of buttons are set using the <toolbartabstop/> element.
+ * This element is invisible, but gets included in the tab order. When one of
+ * these gets focus, it redirects focus to the appropriate button. This avoids
+ * the need to continually manage the tabindex of toolbar buttons in response to
+ * toolbarchanges.
+ */
+
+ToolbarKeyboardNavigator = {
+  // Toolbars we want to be keyboard navigable.
+  kToolbars: [CustomizableUI.AREA_NAVBAR, CustomizableUI.AREA_BOOKMARKS],
+
+  _isButton(aElem) {
+    return aElem.tagName == "toolbarbutton" ||
+      aElem.getAttribute("role") == "button";
+  },
+
+  // Get a TreeWalker which includes only controls which should be keyboard
+  // navigable.
+  _getWalker(aRoot) {
+    if (aRoot._toolbarKeyNavWalker) {
+      return aRoot._toolbarKeyNavWalker;
+    }
+
+    let filter = (aNode) => {
+      if (aNode.tagName == "toolbartabstop") {
+        return NodeFilter.FILTER_ACCEPT;
+      }
+
+      // Special case for the "View site information" button, which isn't
+      // actionable in some cases but is still visible.
+      if (aNode.id == "identity-box" &&
+          document.getElementById("urlbar").getAttribute("pageproxystate") ==
+          "invalid") {
+        return NodeFilter.FILTER_REJECT;
+      }
+
+      // Skip invisible or disabled elements.
+      if (aNode.hidden || aNode.disabled) {
+        return NodeFilter.FILTER_REJECT;
+      }
+      // This width check excludes the overflow button when there's no overflow.
+      let bounds = window.windowUtils.getBoundsWithoutFlushing(aNode);
+      if (bounds.width == 0) {
+        return NodeFilter.FILTER_REJECT;
+      }
+
+      if (this._isButton(aNode)) {
+        return NodeFilter.FILTER_ACCEPT;
+      }
+      return NodeFilter.FILTER_SKIP;
+    };
+    aRoot._toolbarKeyNavWalker = document.createTreeWalker(aRoot,
+      NodeFilter.SHOW_ELEMENT, filter);
+    return aRoot._toolbarKeyNavWalker;
+  },
+
+  init() {
+    for (let id of this.kToolbars) {
+      let toolbar = document.getElementById(id);
+      // When enabled, no toolbar buttons should themselves be tabbable.
+      // We manage toolbar focus completely. This attribute ensures that CSS
+      // doesn't set -moz-user-focus: normal.
+      toolbar.setAttribute("keyNav", "true");
+      for (let stop of toolbar.getElementsByTagName("toolbartabstop")) {
+        // These are invisible, but because they need to be in the tab order,
+        // they can't get display: none or similar. They must therefore be
+        // explicitly hidden for accessibility.
+        stop.setAttribute("aria-hidden", "true");
+        stop.addEventListener("focus", this);
+      }
+      toolbar.addEventListener("keydown", this);
+      toolbar.addEventListener("keypress", this);
+    }
+  },
+
+  uninit() {
+    for (let id of this.kToolbars) {
+      let toolbar = document.getElementById(id);
+      for (let stop of toolbar.getElementsByTagName("toolbartabstop")) {
+        stop.removeEventListener("focus", this);
+      }
+      toolbar.removeEventListener("keydown", this);
+      toolbar.removeEventListener("keypress", this);
+      toolbar.removeAttribute("keyNav");
+    }
+  },
+
+  _focusButton(aButton) {
+    // Toolbar buttons aren't focusable because if they were, clicking them
+    // would focus them, which is undesirable. Therefore, we must make a
+    // button focusable only when we want to focus it.
+    aButton.setAttribute("tabindex", "-1");
+    aButton.focus();
+    // We could remove tabindex now, but even though the button keeps DOM
+    // focus, a11y gets confused because the button reports as not being
+    // focusable. This results in weirdness if the user switches windows and
+    // then switches back. Instead, remove tabindex when the button loses
+    // focus.
+    aButton.addEventListener("blur", this);
+  },
+
+  _onButtonBlur(aEvent) {
+    if (document.activeElement == aEvent.target) {
+      // This event was fired because the user switched windows. This button
+      // will get focus again when the user returns.
+      return;
+    }
+    aEvent.target.removeEventListener("blur", this);
+    aEvent.target.removeAttribute("tabindex");
+  },
+
+  _onTabStopFocus(aEvent) {
+    let toolbar = aEvent.target.closest("toolbar");
+    let walker = this._getWalker(toolbar);
+
+    let oldFocus = aEvent.relatedTarget;
+    if (oldFocus) {
+      // Save this because we might rewind focus and the subsequent focus event
+      // won't get a relatedTarget.
+      this._isFocusMovingBackward =
+        oldFocus.compareDocumentPosition(aEvent.target) &
+        Node.DOCUMENT_POSITION_PRECEDING;
+      if (this._isFocusMovingBackward && oldFocus && this._isButton(oldFocus)) {
+        // Shift+tabbing from a button will land on its toolbartabstop. Skip it.
+        document.commandDispatcher.rewindFocus();
+        return;
+      }
+    }
+
+    walker.currentNode = aEvent.target;
+    let button = walker.nextNode();
+    if (!button || !this._isButton(button)) {
+      // No navigable buttons for this tab stop. Skip it.
+      if (this._isFocusMovingBackward) {
+        document.commandDispatcher.rewindFocus();
+      } else {
+        document.commandDispatcher.advanceFocus();
+      }
+      return;
+    }
+
+    this._focusButton(button);
+  },
+
+  navigateButtons(aToolbar, aPrevious) {
+    let oldFocus = document.activeElement;
+    let walker = this._getWalker(aToolbar);
+    // Start from the current control and walk to the next/previous control.
+    walker.currentNode = oldFocus;
+    let newFocus;
+    if (aPrevious) {
+      newFocus = walker.previousNode();
+    } else {
+      newFocus = walker.nextNode();
+    }
+    if (!newFocus || newFocus.tagName == "toolbartabstop") {
+      // There are no more controls or we hit a tab stop placeholder.
+      return;
+    }
+    this._focusButton(newFocus);
+  },
+
+  _onKeyDown(aEvent) {
+    let focus = document.activeElement;
+    if (aEvent.altKey || aEvent.controlKey || aEvent.metaKey ||
+        aEvent.shiftKey || !this._isButton(focus)) {
+      return;
+    }
+
+    switch (aEvent.key) {
+      case "ArrowLeft":
+        this.navigateButtons(aEvent.currentTarget, true);
+        break;
+      case "ArrowRight":
+        this.navigateButtons(aEvent.currentTarget, false);
+        break;
+      default:
+        return;
+    }
+    aEvent.preventDefault();
+  },
+
+  _onKeyPress(aEvent) {
+    let focus = document.activeElement;
+    if ((aEvent.key != "Enter" && aEvent.key != " ") ||
+        !this._isButton(focus)) {
+      return;
+    }
+
+    if (focus.getAttribute("type") == "menu") {
+      focus.open = true;
+    } else {
+      // Several buttons specifically don't use command events; e.g. because
+      // they want to activate for middle click. Therefore, simulate a
+      // click event.
+      // If this button does handle command events, that won't trigger here.
+      // Command events have their own keyboard handling: keypress for enter
+      // and keyup for space. We rely on that behavior, since there's no way
+      // for us to reliably know what events a button handles.
+      focus.dispatchEvent(new MouseEvent("click", {
+        bubbles: true,
+        ctrlKey: aEvent.ctrlKey,
+        altKey: aEvent.altKey,
+        shiftKey: aEvent.shiftKey,
+        metaKey: aEvent.metaKey,
+      }));
+    }
+    // We deliberately don't call aEvent.preventDefault() here so that enter
+    // will trigger a command event handler if appropriate.
+    aEvent.stopPropagation();
+  },
+
+  handleEvent(aEvent) {
+    switch (aEvent.type) {
+      case "focus":
+        this._onTabStopFocus(aEvent);
+        break;
+      case "keydown":
+        this._onKeyDown(aEvent);
+        break;
+      case "keypress":
+        this._onKeyPress(aEvent);
+        break;
+      case "blur":
+        this._onButtonBlur(aEvent);
+        break;
+    }
+  },
+
+};
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -773,17 +773,17 @@ html|input.urlbar-input {
 #urlbar[pageproxystate=invalid] > #page-action-buttons > .urlbar-page-action,
 #identity-box.chromeUI ~ #page-action-buttons > .urlbar-page-action:not(#star-button-box),
 .urlbar-history-dropmarker[usertyping],
 .urlbar-go-button:not([usertyping]),
 .urlbar-go-button:not([parentfocused="true"]) {
   display: none;
 }
 
-#identity-box {
+#nav-bar:not([keyNav=true]) #identity-box {
   -moz-user-focus: normal;
 }
 
 /* We leave 49ch plus whatever space the download button will need when it
  * appears. Normally this should be 16px for the icon, plus 2 * 2px padding
  * plus the toolbarbutton-inner-padding. We're adding 4px to ensure things
  * like rounding on hidpi don't accidentally result in the button going
  * into overflow.
@@ -976,17 +976,17 @@ html|*.pointerlockfswarning-domain-text:
 }
 
 html|*#fullscreen-exit-button {
   pointer-events: auto;
 }
 
 /* notification anchors should only be visible when their associated
    notifications are */
-.notification-anchor-icon {
+#nav-bar:not([keyNav=true]) .notification-anchor-icon {
   -moz-user-focus: normal;
 }
 
 #blocked-permissions-container > .blocked-permission-icon:not([showing]),
 .notification-anchor-icon:not([showing]) {
   display: none;
 }
 
@@ -1422,9 +1422,13 @@ toolbarpaletteitem > toolbaritem {
 }
 
 @media (min-resolution: 1.1dppx) {
   #sidebar-box[sidebarcommand$="-sidebar-action"] > #sidebar-header > #sidebar-switcher-target > #sidebar-icon {
     list-style-image: var(--webextension-menuitem-image-2x, inherit);
   }
 }
 
+toolbar[keyNav=true]:not([collapsed=true]):not([customizing=true]) toolbartabstop {
+  -moz-user-focus: normal;
+}
+
 %include theme-vars.inc.css
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -143,16 +143,18 @@ XPCOMUtils.defineLazyScriptGetter(this, 
 XPCOMUtils.defineLazyScriptGetter(this, "SearchOneOffs",
                                   "chrome://browser/content/search/search-one-offs.js");
 if (AppConstants.NIGHTLY_BUILD) {
   XPCOMUtils.defineLazyScriptGetter(this, "gWebRender",
                                     "chrome://browser/content/browser-webrender.js");
 }
 
 XPCOMUtils.defineLazyScriptGetter(this, "pktUI", "chrome://pocket/content/main.js");
+XPCOMUtils.defineLazyScriptGetter(this, "ToolbarKeyboardNavigator",
+  "chrome://browser/content/browser-toolbarKeyNav.js");
 
 // lazy service getters
 
 XPCOMUtils.defineLazyServiceGetters(this, {
   classifierService: ["@mozilla.org/url-classifier/dbservice;1", "nsIURIClassifier"],
   Favicons: ["@mozilla.org/browser/favicon-service;1", "nsIFaviconService"],
   gAboutNewTabService: ["@mozilla.org/browser/aboutnewtab-service;1", "nsIAboutNewTabService"],
   gDNSService: ["@mozilla.org/network/dns-service;1", "nsIDNSService"],
@@ -278,16 +280,26 @@ XPCOMUtils.defineLazyGetter(this, "Win7F
         }
       },
       handledOpening: false,
     };
   }
   return null;
 });
 
+XPCOMUtils.defineLazyPreferenceGetter(this, "gToolbarKeyNavEnabled",
+  "browser.toolbars.keyboard_navigation", false,
+  (aPref, aOldVal, aNewVal) => {
+    if (aNewVal) {
+      ToolbarKeyboardNavigator.init();
+    } else {
+      ToolbarKeyboardNavigator.uninit();
+    }
+  });
+
 customElements.setElementCreationCallback("translation-notification", () => {
   Services.scriptloader.loadSubScript(
     "chrome://browser/content/translation-notification.js", window);
 });
 
 var gBrowser;
 var gLastValidURLStr = "";
 var gInPrintPreviewMode = false;
@@ -1379,16 +1391,19 @@ var gBrowserInit = {
     gUIDensity.init();
     TabletModeUpdater.init();
     CombinedStopReload.ensureInitialized();
     gPrivateBrowsingUI.init();
     BrowserSearch.init();
     BrowserPageActions.init();
     gAccessibilityServiceIndicator.init();
     AccessibilityRefreshBlocker.init();
+    if (gToolbarKeyNavEnabled) {
+      ToolbarKeyboardNavigator.init();
+    }
 
     gRemoteControl.updateVisualCue(Marionette.running);
 
     // If we are given a tab to swap in, take care of it before first paint to
     // avoid an about:blank flash.
     let tabToAdopt = this.getTabToAdopt();
     if (tabToAdopt) {
       // Stop the about:blank load
@@ -1925,16 +1940,20 @@ var gBrowserInit = {
     SidebarUI.uninit();
 
     DownloadsButton.uninit();
 
     gAccessibilityServiceIndicator.uninit();
 
     AccessibilityRefreshBlocker.uninit();
 
+    if (gToolbarKeyNavEnabled) {
+      ToolbarKeyboardNavigator.uninit();
+    }
+
     LanguagePrompt.uninit();
 
     BrowserSearch.uninit();
 
     // Now either cancel delayedStartup, or clean up the services initialized from
     // it.
     if (this._boundDelayedStartup) {
       this._cancelDelayedStartup();
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -830,16 +830,17 @@ xmlns="http://www.w3.org/1999/xhtml"
              fullscreentoolbar="true" mode="icons" customizable="true"
              customizationtarget="nav-bar-customization-target"
              overflowable="true"
              overflowbutton="nav-bar-overflow-button"
              overflowtarget="widget-overflow-list"
              overflowpanel="widget-overflow"
              context="toolbar-context-menu">
 
+      <toolbartabstop/>
       <hbox id="nav-bar-customization-target" flex="1">
         <toolbarbutton id="back-button" class="toolbarbutton-1 chromeclass-toolbar-additional"
                        label="&backCmd.label;"
                        removable="false" overflows="false"
                        keepbroadcastattributeswhencustomizing="true"
                        command="Browser:BackOrBackDuplicate"
                        onclick="checkForMiddleClick(this, event);"
                        tooltip="back-button-tooltip"
@@ -883,16 +884,17 @@ xmlns="http://www.w3.org/1999/xhtml"
                        key="goHome"
                        onclick="BrowserHome(event);"
                        cui-areatype="toolbar"
                        tooltiptext="&homeButton.defaultPage.tooltip;"/>
         <toolbarspring cui-areatype="toolbar" class="chromeclass-toolbar-additional"/>
         <toolbaritem id="urlbar-container" flex="400" persist="width"
                      removable="false"
                      class="chromeclass-location" overflows="false">
+            <toolbartabstop/>
             <textbox id="urlbar" flex="1"
                      placeholder="&urlbar.placeholder2;"
                      defaultPlaceholder="&urlbar.placeholder2;"
                      focused="true"
                      type="autocomplete"
                      autocompletesearch="unifiedcomplete"
                      autocompletesearchparam="enable-actions"
                      autocompletepopup="PopupAutoCompleteRichResult"
@@ -1002,16 +1004,17 @@ xmlns="http://www.w3.org/1999/xhtml"
                   <label id="identity-icon-country-label" class="plain"/>
                 </hbox>
               </box>
               <box id="urlbar-display-box" align="center">
                 <label id="switchtab" class="urlbar-display urlbar-display-switchtab" value="&urlbar.switchToTab.label;"/>
                 <label id="extension" class="urlbar-display urlbar-display-extension" value="&urlbar.extension.label;"/>
               </box>
               <hbox id="page-action-buttons" context="pageActionContextMenu">
+                <toolbartabstop/>
                 <hbox id="contextual-feature-recommendation" role="button" hidden="true">
                   <hbox id="cfr-label-container">
                     <label id="cfr-label"/>
                   </hbox>
                   <image id="cfr-button"
                          class="urlbar-icon urlbar-page-action"
                          role="presentation"/>
                 </hbox>
@@ -1059,16 +1062,17 @@ xmlns="http://www.w3.org/1999/xhtml"
                          role="button"/>
                   <hbox id="star-button-animatable-box">
                     <image id="star-button-animatable-image"
                            role="presentation"/>
                   </hbox>
                 </hbox>
               </hbox>
             </textbox>
+            <toolbartabstop/>
         </toolbaritem>
 
         <toolbarspring cui-areatype="toolbar" class="chromeclass-toolbar-additional"/>
 
         <!-- This is a placeholder for the Downloads Indicator.  It is visible
              during the customization of the toolbar, in the palette, and before
              the Downloads Indicator overlay is loaded. -->
         <toolbarbutton id="downloads-button"
@@ -1154,16 +1158,17 @@ xmlns="http://www.w3.org/1999/xhtml"
 
     <toolbar id="PersonalToolbar"
              mode="icons"
              class="browser-toolbar chromeclass-directories"
              context="toolbar-context-menu"
              toolbarname="&personalbarCmd.label;" accesskey="&personalbarCmd.accesskey;"
              collapsed="true"
              customizable="true">
+      <toolbartabstop skipintoolbarset="true"/>
       <toolbaritem id="personal-bookmarks"
                    title="&bookmarksToolbarItem.label;"
                    cui-areatype="toolbar"
                    removable="true">
         <toolbarbutton id="bookmarks-toolbar-placeholder"
                        class="bookmark-item"
                        label="&bookmarksToolbarItem.label;"/>
         <toolbarbutton id="bookmarks-toolbar-button"
@@ -1323,17 +1328,19 @@ xmlns="http://www.w3.org/1999/xhtml"
       </toolbarbutton>
 
       <toolbaritem id="search-container"
                    class="chromeclass-toolbar-additional"
                    title="&searchItem.title;"
                    align="center"
                    flex="100"
                    persist="width">
+        <toolbartabstop/>
         <searchbar id="searchbar" flex="1"/>
+        <toolbartabstop/>
       </toolbaritem>
     </toolbarpalette>
   </toolbox>
 
   <hbox id="fullscr-toggler" hidden="true"/>
 
   <deck id="content-deck" flex="1">
     <hbox flex="1" id="browser">
--- a/browser/base/content/test/keyboard/browser.ini
+++ b/browser/base/content/test/keyboard/browser.ini
@@ -1,1 +1,3 @@
 [browser_toolbarButtonKeyPress.js]
+[browser_toolbarKeyNav.js]
+support-files = !/browser/base/content/test/permissions/permissions.html
--- a/browser/base/content/test/keyboard/browser_toolbarButtonKeyPress.js
+++ b/browser/base/content/test/keyboard/browser_toolbarButtonKeyPress.js
@@ -25,16 +25,22 @@ function waitForLocationChange() {
         resolve();
       },
     };
     gBrowser.addProgressListener(wpl);
   });
   return promise;
 }
 
+add_task(async function setPref() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["browser.toolbars.keyboard_navigation", true]],
+  });
+});
+
 // Test activation of the app menu button from the keyboard.
 // The app menu should appear and focus should move inside it.
 add_task(async function testAppMenuButtonPress() {
   let button = document.getElementById("PanelUI-menu-button");
   forceFocus(button);
   let focused = BrowserTestUtils.waitForEvent(window.PanelUI.mainView, "focus", true);
   EventUtils.synthesizeKey(" ");
   await focused;
@@ -81,16 +87,17 @@ add_task(async function testDeveloperBut
   EventUtils.synthesizeKey(" ");
   await focused;
   ok(true, "Focus inside Developer menu after toolbar button pressed");
   let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
   view.closest("panel").hidePopup();
   await hidden;
   CustomizableUI.reset();
 });
+
 // Test that the Developer menu doesn't open when a key other than space or
 // enter is pressed .
 add_task(async function testDeveloperButtonWrongKey() {
   CustomizableUI.addWidgetToArea("developer-button", CustomizableUI.AREA_NAVBAR);
   let button = document.getElementById("developer-button");
   forceFocus(button);
   EventUtils.synthesizeKey("KEY_Tab");
   await TestUtils.waitForTick();
@@ -154,8 +161,85 @@ add_task(async function testSendTabToDev
     ok(view.contains(document.activeElement),
        "Focus inside Page Actions menu after toolbar button pressed");
     let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
     view.closest("panel").hidePopup();
     await hidden;
     PageActions.actionForID("sendToDevice").pinnedToUrlbar = false;
   });
 });
+
+// Test activation of the Reload button from the keyboard.
+// This is a toolbarbutton with a click handler and no command handler, but
+// the toolbar keyboard navigation code should handle keyboard activation.
+add_task(async function testReloadButtonPress() {
+  await BrowserTestUtils.withNewTab("https://example.com", async function(aBrowser) {
+    let button = document.getElementById("reload-button");
+    await TestUtils.waitForCondition(() => !button.disabled);
+    forceFocus(button);
+    let loaded = BrowserTestUtils.browserLoaded(aBrowser);
+    EventUtils.synthesizeKey(" ");
+    await loaded;
+    ok(true, "Page loaded after Reload button pressed");
+  });
+});
+
+// Test activation of the Sidebars button from the keyboard.
+// This is a toolbarbutton with a command handler.
+add_task(async function testSidebarsButtonPress() {
+  let button = document.getElementById("sidebar-button");
+  ok(!button.checked, "Sidebars button not checked at start of test");
+  let sidebarBox = document.getElementById("sidebar-box");
+  ok(sidebarBox.hidden, "Sidebar hidden at start of test");
+  forceFocus(button);
+  EventUtils.synthesizeKey(" ");
+  await TestUtils.waitForCondition(() => button.checked);
+  ok(true, "Sidebars button checked after press");
+  ok(!sidebarBox.hidden, "Sidebar visible after press");
+  // Make sure the sidebar is fully loaded before we hide it.
+  // Otherwise, the unload event might call JS which isn't loaded yet.
+  // We can't use BrowserTestUtils.browserLoaded because it fails on non-tab
+  // docs. Instead, wait for something in the JS script.
+  let sidebarWin = document.getElementById("sidebar").contentWindow;
+  await TestUtils.waitForCondition(() => sidebarWin.PlacesUIUtils);
+  forceFocus(button);
+  EventUtils.synthesizeKey(" ");
+  await TestUtils.waitForCondition(() => !button.checked);
+  ok(true, "Sidebars button not checked after press");
+  ok(sidebarBox.hidden, "Sidebar hidden after press");
+});
+
+// Test activation of the Bookmark this page button from the keyboard.
+// This is an image with a click handler on its parent and no command handler,
+// but the toolbar keyboard navigation code should handle keyboard activation.
+add_task(async function testBookmarkButtonPress() {
+  await BrowserTestUtils.withNewTab("https://example.com", async function(aBrowser) {
+    let button = document.getElementById("star-button");
+    forceFocus(button);
+    let panel = document.getElementById("editBookmarkPanel");
+    let focused = BrowserTestUtils.waitForEvent(panel, "focus", true);
+    EventUtils.synthesizeKey(" ");
+    await focused;
+    ok(true, "Focus inside edit bookmark panel after Bookmark button pressed");
+    let hidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+    EventUtils.synthesizeKey("KEY_Escape");
+    await hidden;
+  });
+});
+
+// Test activation of the Bookmarks Menu button from the keyboard.
+// This is a button with type="menu".
+// The Bookmarks Menu should appear.
+add_task(async function testBookmarksmenuButtonPress() {
+  CustomizableUI.addWidgetToArea("bookmarks-menu-button",
+                                 CustomizableUI.AREA_NAVBAR);
+  let button = document.getElementById("bookmarks-menu-button");
+  forceFocus(button);
+  let menu = document.getElementById("BMB_bookmarksPopup");
+  let shown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+  EventUtils.synthesizeKey(" ");
+  await shown;
+  ok(true, "Bookmarks Menu shown after toolbar button pressed");
+  let hidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+  menu.hidePopup();
+  await hidden;
+  CustomizableUI.reset();
+});
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser_toolbarKeyNav.js
@@ -0,0 +1,219 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test browser toolbar keyboard navigation.
+ * These tests assume the default browser configuration for toolbars unless
+ * otherwise specified.
+ */
+
+const PERMISSIONS_PAGE = "https://example.com/browser/browser/base/content/test/permissions/permissions.html";
+
+async function expectFocusAfterKey(aKey, aFocus, aAncestorOk = false) {
+  let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/);
+  let shift = Boolean(res[1]);
+  let key;
+  if (res[2]) {
+    key = res[2]; // Character.
+  } else {
+    key = "KEY_" + res[3]; // Tab, ArrowRight, etc.
+  }
+  let expected;
+  let friendlyExpected;
+  if (typeof aFocus == "string") {
+    expected = document.getElementById(aFocus);
+    friendlyExpected = aFocus;
+  } else {
+    expected = aFocus;
+    if (aFocus == gURLBar.inputField) {
+      friendlyExpected = "URL bar input";
+    } else if (aFocus == gBrowser.selectedBrowser) {
+      friendlyExpected = "Web document";
+    }
+  }
+  let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk);
+  EventUtils.synthesizeKey(key, {shiftKey: shift});
+  await focused;
+  ok(true, friendlyExpected + " focused after " + aKey + " pressed");
+}
+
+function startFromUrlBar() {
+  gURLBar.focus();
+  is(document.activeElement, gURLBar.inputField,
+     "URL bar focused for start of test");
+}
+
+// The Reload button is disabled for a short time even after the page finishes
+// loading. Wait for it to be enabled.
+async function waitUntilReloadEnabled() {
+  let button = document.getElementById("reload-button");
+  await TestUtils.waitForCondition(() => !button.disabled);
+}
+
+add_task(async function setPref() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["browser.toolbars.keyboard_navigation", true],
+      ["accessibility.tabfocus", 7],
+    ],
+  });
+});
+
+// Test tab stops with no page loaded.
+add_task(async function testTabStopsNoPage() {
+  await BrowserTestUtils.withNewTab("about:blank", async function() {
+    startFromUrlBar();
+    await expectFocusAfterKey("Shift+Tab", "home-button");
+    await expectFocusAfterKey("Shift+Tab", "tabbrowser-tabs", true);
+    await expectFocusAfterKey("Tab", "home-button");
+    await expectFocusAfterKey("Tab", gURLBar.inputField);
+    await expectFocusAfterKey("Tab", "library-button");
+    await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+  });
+});
+
+// Test tab stops with a page loaded.
+add_task(async function testTabStopsPageLoaded() {
+  await BrowserTestUtils.withNewTab("https://example.com", async function() {
+    await waitUntilReloadEnabled();
+    startFromUrlBar();
+    await expectFocusAfterKey("Shift+Tab", "identity-box");
+    await expectFocusAfterKey("Shift+Tab", "reload-button");
+    await expectFocusAfterKey("Shift+Tab", "tabbrowser-tabs", true);
+    await expectFocusAfterKey("Tab", "reload-button");
+    await expectFocusAfterKey("Tab", "identity-box");
+    await expectFocusAfterKey("Tab", gURLBar.inputField);
+    await expectFocusAfterKey("Tab", "pageActionButton");
+    await expectFocusAfterKey("Tab", "library-button");
+    await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+  });
+});
+
+// Test tab stops with a notification anchor visible.
+// The notification anchor should not get its own tab stop.
+add_task(async function testTabStopsWithNotification() {
+  await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function(aBrowser) {
+    let popupShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+    // Request a permission.
+    BrowserTestUtils.synthesizeMouseAtCenter("#geo", {}, aBrowser);
+    await popupShown;
+    startFromUrlBar();
+    // If the notification anchor were in the tab order, the next shift+tab
+    // would focus it instead of #identity-box.
+    await expectFocusAfterKey("Shift+Tab", "identity-box");
+  });
+});
+
+// Test tab stops with the Bookmarks toolbar visible.
+add_task(async function testTabStopsWithBookmarksToolbar() {
+  await BrowserTestUtils.withNewTab("about:blank", async function() {
+    CustomizableUI.setToolbarVisibility("PersonalToolbar", true);
+    startFromUrlBar();
+    await expectFocusAfterKey("Tab", "library-button");
+    await expectFocusAfterKey("Tab", "PersonalToolbar", true);
+    await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+
+    // Make sure the Bookmarks toolbar is no longer tabbable once hidden.
+    CustomizableUI.setToolbarVisibility("PersonalToolbar", false);
+    startFromUrlBar();
+    await expectFocusAfterKey("Tab", "library-button");
+    await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+  });
+});
+
+// Test a focusable toolbartabstop which has no navigable buttons.
+add_task(async function testTabStopNoButtons() {
+  await BrowserTestUtils.withNewTab("about:blank", async function() {
+    // The Back, Forward and Reload buttons are all currently disabled.
+    // The Home button is the only other button at that tab stop.
+    CustomizableUI.removeWidgetFromArea("home-button");
+    startFromUrlBar();
+    await expectFocusAfterKey("Shift+Tab", "tabbrowser-tabs", true);
+    await expectFocusAfterKey("Tab", gURLBar.inputField);
+    CustomizableUI.reset();
+    // Make sure the button is reachable now that it has been re-added.
+    await expectFocusAfterKey("Shift+Tab", "home-button", true);
+  });
+});
+
+// Test that right/left arrows move through toolbarbuttons.
+// This also verifies that:
+// 1. Right/left arrows do nothing when at the edges; and
+// 2. The overflow menu button can't be reached by right arrow when it isn't
+// visible.
+add_task(async function testArrowsToolbarbuttons() {
+  await BrowserTestUtils.withNewTab("about:blank", async function() {
+    startFromUrlBar();
+    await expectFocusAfterKey("Tab", "library-button");
+    EventUtils.synthesizeKey("KEY_ArrowLeft");
+    is(document.activeElement.id, "library-button",
+       "ArrowLeft at end of button group does nothing");
+    await expectFocusAfterKey("ArrowRight", "sidebar-button");
+    // This next check also confirms that the overflow menu button is skipped,
+    // since it is currently invisible.
+    await expectFocusAfterKey("ArrowRight", "PanelUI-menu-button");
+    EventUtils.synthesizeKey("KEY_ArrowRight");
+    is(document.activeElement.id, "PanelUI-menu-button",
+       "ArrowRight at end of button group does nothing");
+    await expectFocusAfterKey("ArrowLeft", "sidebar-button");
+    await expectFocusAfterKey("ArrowLeft", "library-button");
+  });
+});
+
+// Test that right/left arrows move through buttons wihch aren't toolbarbuttons
+// but have role="button".
+add_task(async function testArrowsRoleButton() {
+  await BrowserTestUtils.withNewTab("https://example.com", async function() {
+    startFromUrlBar();
+    await expectFocusAfterKey("Tab", "pageActionButton");
+    await expectFocusAfterKey("ArrowRight", "pocket-button");
+    await expectFocusAfterKey("ArrowRight", "star-button");
+    await expectFocusAfterKey("ArrowLeft", "pocket-button");
+    await expectFocusAfterKey("ArrowLeft", "pageActionButton");
+  });
+});
+
+// Test that right/left arrows do not land on disabled buttons.
+add_task(async function testArrowsDisabledButtons() {
+  await BrowserTestUtils.withNewTab("https://example.com", async function(aBrowser) {
+    await waitUntilReloadEnabled();
+    startFromUrlBar();
+    await expectFocusAfterKey("Shift+Tab", "identity-box");
+    // Back and Forward buttons are disabled.
+    await expectFocusAfterKey("Shift+Tab", "reload-button");
+    EventUtils.synthesizeKey("KEY_ArrowLeft");
+    is(document.activeElement.id, "reload-button",
+       "ArrowLeft on Reload button when prior buttons disabled does nothing");
+
+    BrowserTestUtils.loadURI(aBrowser, "https://example.com/2");
+    await BrowserTestUtils.browserLoaded(aBrowser);
+    await waitUntilReloadEnabled();
+    startFromUrlBar();
+    await expectFocusAfterKey("Shift+Tab", "identity-box");
+    await expectFocusAfterKey("Shift+Tab", "back-button");
+    // Forward button is still disabled.
+    await expectFocusAfterKey("ArrowRight", "reload-button");
+  });
+});
+
+// Test that right arrow reaches the overflow menu button when it is visible.
+add_task(async function testArrowsOverflowButton() {
+  await BrowserTestUtils.withNewTab("about:blank", async function() {
+    // Move something to the overflow menu to make the button appear.
+    CustomizableUI.addWidgetToArea("home-button", CustomizableUI.AREA_FIXED_OVERFLOW_PANEL);
+    startFromUrlBar();
+    await expectFocusAfterKey("Tab", "library-button");
+    await expectFocusAfterKey("ArrowRight", "sidebar-button");
+    await expectFocusAfterKey("ArrowRight", "nav-bar-overflow-button");
+    await expectFocusAfterKey("ArrowRight", "PanelUI-menu-button");
+    await expectFocusAfterKey("ArrowLeft", "nav-bar-overflow-button");
+    // Make sure the button is not reachable once it is invisible again.
+    await expectFocusAfterKey("ArrowRight", "PanelUI-menu-button");
+    CustomizableUI.reset();
+    // Flush layout so its invisibility can be detected.
+    document.getElementById("nav-bar-overflow-button").clientWidth;
+    await expectFocusAfterKey("ArrowLeft", "sidebar-button");
+  });
+});
--- a/browser/base/jar.mn
+++ b/browser/base/jar.mn
@@ -50,17 +50,18 @@ browser.jar:
         content/browser/browser-media.js              (content/browser-media.js)
         content/browser/browser-pageActions.js        (content/browser-pageActions.js)
         content/browser/browser-places.js             (content/browser-places.js)
         content/browser/browser-plugins.js            (content/browser-plugins.js)
         content/browser/browser-safebrowsing.js       (content/browser-safebrowsing.js)
         content/browser/browser-sidebar.js            (content/browser-sidebar.js)
         content/browser/browser-siteIdentity.js       (content/browser-siteIdentity.js)
         content/browser/browser-sync.js               (content/browser-sync.js)
-        content/browser/browser-tabsintitlebar.js       (content/browser-tabsintitlebar.js)
+        content/browser/browser-tabsintitlebar.js     (content/browser-tabsintitlebar.js)
+        content/browser/browser-toolbarKeyNav.js      (content/browser-toolbarKeyNav.js)
         content/browser/browser-thumbnails.js         (content/browser-thumbnails.js)
         content/browser/browser-webrender.js          (content/browser-webrender.js)
         content/browser/tab-content.js                (content/tab-content.js)
         content/browser/content.js                    (content/content.js)
         content/browser/defaultthemes/1.header.jpg    (content/defaultthemes/1.header.jpg)
         content/browser/defaultthemes/1.icon.jpg      (content/defaultthemes/1.icon.jpg)
         content/browser/defaultthemes/1.preview.jpg   (content/defaultthemes/1.preview.jpg)
         content/browser/defaultthemes/2.header.jpg    (content/defaultthemes/2.header.jpg)