Bug 1366205 - add a browser mochitest with full coverage of the new menu panel keyboard navigation feature. r?Gijs draft
authorMike de Boer <mdeboer@mozilla.com>
Fri, 26 May 2017 13:44:55 +0200
changeset 585060 705e6b48a6e9f66d459049b1cce38043580e2295
parent 584877 5d6fe59a9a5dfd2d381d2cdfdc968d3305b417cf
child 585163 79f70c40aa6930eece8ddb6ce52451ef09ef535d
child 585166 cc36bdfab2cbb473881bc6665cd9ad015b59a4a4
push id60993
push usermdeboer@mozilla.com
push dateFri, 26 May 2017 11:47:58 +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;
+});