Bug 1366205 - add a browser mochitest with full coverage of the new menu panel keyboard navigation feature. r=Gijs
authorMike de Boer <mdeboer@mozilla.com>
Fri, 26 May 2017 13:44:55 +0200
changeset 585286 29ed157a7ca229e9f6573b09b1e94c2021f815fc
parent 585285 5a8db189930cedc0ec845ced1970837c187b9003
child 585287 25713e57068200e3f4705aded940270531fd8c03
push id61093
push userdgottwald@mozilla.com
push dateFri, 26 May 2017 20:16:26 +0000
reviewersGijs
bugs1366205
milestone55.0a1
Bug 1366205 - add a browser mochitest with full coverage of the new menu panel keyboard navigation feature. r=Gijs This also fixes two issues I found whilst writing the tests: 1. Exclude hidden items from the set of navigable buttons and 2. Exclude disabled items from the set of navigable buttons whilst navigating, because they may get disabled in the meantime (like with the edit controls). MozReview-Commit-ID: 5WThVoTZjbV
browser/components/customizableui/PanelMultiView.jsm
browser/components/customizableui/test/browser.ini
browser/components/customizableui/test/browser_panel_keyboard_navigation.js
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -213,18 +213,22 @@ this.PanelMultiView = class {
     return panel;
   }
   get _keyNavigationMap() {
     if (!this.__keyNavigationMap)
       this.__keyNavigationMap = new Map();
     return this.__keyNavigationMap;
   }
 
-  constructor(xulNode) {
+  constructor(xulNode, testMode = false) {
     this.node = xulNode;
+    // If `testMode` is `true`, the consumer is only interested in accessing the
+    // methods of this instance. (E.g. in unit tests.)
+    if (testMode)
+      return;
 
     this._currentSubView = this._anchorElement = this._subViewObserver = null;
     this._mainViewHeight = 0;
     this.__transitioning = this._ignoreMutations = false;
 
     const {document, window} = this;
 
     this._clickCapturer =
@@ -567,16 +571,19 @@ this.PanelMultiView = class {
               nodeToAnimate.style.removeProperty("width");
 
               if (!reverse)
                 viewNode.style.removeProperty("margin-inline-start");
               if (aAnchor)
                 aAnchor.removeAttribute("open");
 
               this._viewContainer.removeAttribute("transition-reverse");
+
+              evt = new window.CustomEvent("ViewShown", { bubbles: true, cancelable: false });
+              viewNode.dispatchEvent(evt);
             }, { once: true });
           });
         }, { once: true });
       } else if (!this.panelViews) {
         this._transitionHeight(() => {
           viewNode.setAttribute("current", true);
           this.node.setAttribute("viewtype", "subview");
           // Now that the subview is visible, we can check the height of the
@@ -818,25 +825,25 @@ this.PanelMultiView = class {
     switch (keyCode) {
       case "ArrowDown":
       case "ArrowUp": {
         stop();
         let isDown = (keyCode == "ArrowDown");
         let maxIdx = buttons.length - 1;
         let buttonIndex = isDown ? 0 : maxIdx;
         if (typeof navMap.selected == "number") {
-          if (isDown) {
-            buttonIndex = ++navMap.selected;
-            if (buttonIndex > maxIdx)
-              buttonIndex = 0;
-          } else {
-            buttonIndex = --navMap.selected;
-            if (buttonIndex < 0)
-              buttonIndex = maxIdx;
-          }
+          // Buttons may get selected whilst the panel is shown, so add an extra
+          // check here.
+          do {
+            buttonIndex = navMap.selected = (navMap.selected + (isDown ? 1 : -1));
+          } while (buttons[buttonIndex] && buttons[buttonIndex].disabled)
+          if (isDown && buttonIndex > maxIdx)
+            buttonIndex = 0;
+          else if (!isDown && buttonIndex < 0)
+            buttonIndex = maxIdx;
         }
         let button = buttons[buttonIndex];
         button.focus();
         navMap.selected = buttonIndex;
         break;
       }
       case "ArrowLeft":
       case "ArrowRight": {
@@ -894,17 +901,21 @@ this.PanelMultiView = class {
    *
    * @param  {nsIDOMNode} view
    * @return {Array}
    */
   _getNavigableElements(view) {
     let buttons = Array.from(view.querySelectorAll(".subviewbutton:not([disabled])"));
     if (this._canGoBack(view))
       buttons.unshift(view.backButton);
-    return buttons;
+    let dwu = this._dwu;
+    return buttons.filter(button => {
+      let bounds = dwu.getBoundsWithoutFlushing(button);
+      return bounds.width > 0 && bounds.height > 0;
+    });
   }
 
   /**
    * If the main view or a subview contains wrapping elements, the attribute
    * "descriptionheightworkaround" should be set on the view to force all the
    * "description" elements to a fixed height. If the attribute is set and the
    * visibility, contents, or width of any of these elements changes, this
    * function should be called to refresh the calculated heights.
--- a/browser/components/customizableui/test/browser.ini
+++ b/browser/components/customizableui/test/browser.ini
@@ -144,16 +144,17 @@ skip-if = os == "mac"
 [browser_1087303_button_preferences.js]
 [browser_1089591_still_customizable_after_reset.js]
 [browser_1096763_seen_widgets_post_reset.js]
 [browser_1161838_inserted_new_default_buttons.js]
 [browser_bootstrapped_custom_toolbar.js]
 [browser_customizemode_contextmenu_menubuttonstate.js]
 [browser_exit_background_customize_mode.js]
 [browser_overflow_use_subviews.js]
+[browser_panel_keyboard_navigation.js]
 [browser_panel_toggle.js]
 [browser_panelUINotifications.js]
 [browser_panelUINotifications_fullscreen.js]
 [browser_panelUINotifications_multiWindow.js]
 [browser_switch_to_customize_mode.js]
 [browser_synced_tabs_menu.js]
 [browser_check_tooltips_in_navbar.js]
 [browser_editcontrols_update.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js
@@ -0,0 +1,140 @@
+"use strict";
+
+/**
+ * Test keyboard navigation in the app menu panel.
+ */
+
+const {PanelMultiView} = Cu.import("resource:///modules/PanelMultiView.jsm", {});
+const kHelpButtonId = "appMenu-help-button";
+let gHelperInstance;
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({set: [["browser.photon.structure.enabled", true]]});
+  gHelperInstance = new PanelMultiView(PanelUI.panel, true);
+});
+
+add_task(async function testUpDownKeys() {
+  let promise = promisePanelShown(window);
+  PanelUI.show();
+  await promise;
+
+  let buttons = gHelperInstance._getNavigableElements(PanelUI.mainView);
+
+  for (let button of buttons) {
+    if (button.disabled)
+      continue;
+    EventUtils.synthesizeKey("KEY_ArrowDown", { code: "ArrowDown" });
+    Assert.equal(document.commandDispatcher.focusedElement, button,
+      "The correct button should be focused after navigating downward");
+  }
+
+  EventUtils.synthesizeKey("KEY_ArrowDown", { code: "ArrowDown" });
+  Assert.equal(document.commandDispatcher.focusedElement, buttons[0],
+    "Pressing upwards should cycle around and select the first button again");
+
+  for (let i = buttons.length - 1; i >= 0; --i) {
+    let button = buttons[i];
+    if (button.disabled)
+      continue;
+    EventUtils.synthesizeKey("KEY_ArrowUp", { code: "ArrowUp" });
+    Assert.equal(document.commandDispatcher.focusedElement, button,
+      "The first button should be focused after navigating upward");
+  }
+
+  promise = promisePanelHidden(window);
+  PanelUI.hide();
+  await promise;
+});
+
+add_task(async function testEnterKeyBehaviors() {
+  let promise = promisePanelShown(window);
+  PanelUI.show();
+  await promise;
+
+  let buttons = gHelperInstance._getNavigableElements(PanelUI.mainView);
+
+  // Navigate to the 'Help' button, which points to a subview.
+  EventUtils.synthesizeKey("KEY_ArrowUp", { code: "ArrowUp" });
+  let focusedElement = document.commandDispatcher.focusedElement;
+  Assert.equal(focusedElement, buttons[buttons.length - 1],
+    "The last button should be focused after navigating upward");
+
+  promise = BrowserTestUtils.waitForEvent(PanelUI.helpView, "ViewShown");
+  // Make sure the Help button is in focus.
+  while (!focusedElement || !focusedElement.id || focusedElement.id != kHelpButtonId) {
+    EventUtils.synthesizeKey("KEY_ArrowUp", { code: "ArrowUp" });
+    focusedElement = document.commandDispatcher.focusedElement;
+  }
+  EventUtils.synthesizeKey("VK_RETURN", { code: "Enter" });
+  await promise;
+
+  let helpButtons = gHelperInstance._getNavigableElements(PanelUI.helpView);
+  Assert.ok(helpButtons[0].classList.contains("subviewbutton-back"),
+    "First button in help view should be a back button");
+
+  // For posterity, check navigating the subview using up/ down arrow keys as well.
+  for (let i = helpButtons.length - 1; i >= 0; --i) {
+    let button = helpButtons[i];
+    if (button.disabled)
+      continue;
+    EventUtils.synthesizeKey("KEY_ArrowUp", { code: "ArrowUp" });
+    focusedElement = document.commandDispatcher.focusedElement;
+    Assert.equal(focusedElement, button, "The first button should be focused after navigating upward");
+  }
+
+  // Make sure the back button is in focus again.
+  while (focusedElement != helpButtons[0]) {
+    EventUtils.synthesizeKey("KEY_ArrowDown", { code: "ArrowDown" });
+    focusedElement = document.commandDispatcher.focusedElement;
+  }
+
+  // The first button is the back button. Hittin Enter should navigate us back.
+  promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown");
+  EventUtils.synthesizeKey("VK_RETURN", { code: "Enter" });
+  await promise;
+
+  // Let's test a 'normal' command button.
+  focusedElement = document.commandDispatcher.focusedElement;
+  const kFindButtonId = "appMenu-find-button";
+  while (!focusedElement || !focusedElement.id || focusedElement.id != kFindButtonId) {
+    EventUtils.synthesizeKey("KEY_ArrowUp", { code: "ArrowUp" });
+    focusedElement = document.commandDispatcher.focusedElement;
+  }
+  Assert.equal(focusedElement.id, kFindButtonId, "Find button should be selected");
+
+  promise = promisePanelHidden(window);
+  EventUtils.synthesizeKey("VK_RETURN", { code: "Enter" });
+  await promise;
+
+  Assert.ok(!gFindBar.hidden, "Findbar should have opened");
+  gFindBar.close();
+});
+
+add_task(async function testLeftRightKeys() {
+  let promise = promisePanelShown(window);
+  PanelUI.show();
+  await promise;
+
+  // Navigate to the 'Help' button, which points to a subview.
+  let focusedElement = document.commandDispatcher.focusedElement;
+  while (!focusedElement || !focusedElement.id || focusedElement.id != kHelpButtonId) {
+    EventUtils.synthesizeKey("KEY_ArrowUp", { code: "ArrowUp" });
+    focusedElement = document.commandDispatcher.focusedElement;
+  }
+  Assert.equal(focusedElement.id, kHelpButtonId, "The last button should be focused after navigating upward");
+
+  // Hitting ArrowRight on a button that points to a subview should navigate us
+  // there.
+  promise = BrowserTestUtils.waitForEvent(PanelUI.helpView, "ViewShown");
+  EventUtils.synthesizeKey("KEY_ArrowRight", { code: "ArrowRight" });
+  await promise;
+
+  // Hitting ArrowLeft should navigate us back.
+  promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown");
+  EventUtils.synthesizeKey("KEY_ArrowLeft", { code: "ArrowLeft" });
+  await promise;
+
+  promise = promisePanelHidden(window);
+  PanelUI.hide();
+  await promise;
+});