Bug 1432016 - Part 4 - Move keyboard navigation to the PanelView class. r=Gijs draft
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Mon, 22 Jan 2018 13:17:27 +0000
changeset 723062 8595c2829999a3a1d5282de1a078f36c751d383c
parent 723061 25f0378c51b6d5a08fca22b561e239a245244e0a
child 746757 dd5bc86391c3a3335fae4afbb245ed864d34d413
push id96314
push userpaolo.mozmail@amadzone.org
push dateMon, 22 Jan 2018 13:20:54 +0000
reviewersGijs
bugs1432016
milestone59.0a1
Bug 1432016 - Part 4 - Move keyboard navigation to the PanelView class. r=Gijs This allows removing the separate keyboard navigation map. MozReview-Commit-ID: 2N0wflAvg7Y
browser/components/customizableui/PanelMultiView.jsm
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -190,21 +190,16 @@ this.PanelMultiView = class extends this
   }
   /**
    * @return {Promise} showSubView() returns a promise, which is kept here for
    *                   random access.
    */
   get currentShowPromise() {
     return this._currentShowPromise || Promise.resolve();
   }
-  get _keyNavigationMap() {
-    if (!this.__keyNavigationMap)
-      this.__keyNavigationMap = new Map();
-    return this.__keyNavigationMap;
-  }
 
   connect() {
     this.knownViews = new Set(Array.from(
       this.node.getElementsByTagName("panelview"),
       node => PanelView.forNode(node)));
     this.openViews = [];
     this._mainViewHeight = 0;
     this.__transitioning = false;
@@ -292,32 +287,27 @@ this.PanelMultiView = class extends this
     for (let subview of subviews) {
       // XBL lists the 'children' XBL element explicitly. :-(
       if (subview.nodeName != "children")
         this._panelViewCache.appendChild(subview);
     }
   }
 
   goBack() {
+    if (this.openViews.length < 2) {
+      // This may be called by keyboard navigation or external code when only
+      // the main view is open.
+      return;
+    }
+
     let previous = this.openViews.pop().node;
     let current = this._currentSubView;
     return this.showSubView(current, null, previous);
   }
 
-  /**
-   * Checks whether it is possible to navigate backwards currently. Returns
-   * false if this is the panelmultiview's mainview, true otherwise.
-   *
-   * @param  {panelview} view View to check, defaults to the currently active view.
-   * @return {Boolean}
-   */
-  _canGoBack(view = this._currentSubView) {
-    return view.id != this._mainViewId;
-  }
-
   showMainView() {
     if (!this.node || !this._mainViewId)
       return Promise.resolve();
 
     return this.showSubView(this._mainView);
   }
 
   /**
@@ -432,17 +422,17 @@ this.PanelMultiView = class extends this
         }
       }
 
       // Now we have to transition the panel. If we've got an older transition
       // still running, make sure to clean it up.
       await this._cleanupTransitionPhase();
       if (playTransition) {
         await this._transitionViews(previousViewNode, viewNode, reverse, previousRect, aAnchor);
-        this._updateKeyboardFocus(viewNode);
+        nextPanelView.focusSelectedElement();
       } else {
         this.hideAllViewsExcept(nextPanelView);
       }
     })().catch(e => Cu.reportError(e));
     return this._currentShowPromise;
   }
 
   /**
@@ -617,24 +607,25 @@ this.PanelMultiView = class extends this
     if (!details || !this.node)
       return;
 
     let {phase, previousViewNode, viewNode, reverse, resolve, listener, cancelListener, anchor} = details;
     if (details == this._transitionDetails)
       this._transitionDetails = null;
 
     let nextPanelView = PanelView.forNode(viewNode);
+    let prevPanelView = PanelView.forNode(previousViewNode);
 
     // Do the things we _always_ need to do whenever the transition ends or is
     // interrupted.
     this.hideAllViewsExcept(nextPanelView);
     previousViewNode.removeAttribute("in-transition");
     viewNode.removeAttribute("in-transition");
     if (reverse)
-      this._resetKeyNavigation(previousViewNode);
+      prevPanelView.clearNavigation();
 
     if (anchor)
       anchor.removeAttribute("open");
 
     if (phase >= TRANSITION_PHASES.START) {
       this._panel.removeAttribute("width");
       this._panel.removeAttribute("height");
       // Myeah, panel layout auto-resizing is a funky thing. We'll wait
@@ -716,20 +707,23 @@ this.PanelMultiView = class extends this
 
   handleEvent(aEvent) {
     if (aEvent.type.startsWith("popup") && aEvent.target != this._panel) {
       // Shouldn't act on e.g. context menus being shown from within the panel.
       return;
     }
     switch (aEvent.type) {
       case "keydown":
-        this._keyNavigation(aEvent);
+        if (!this._transitioning) {
+          PanelView.forNode(this._currentSubView)
+                   .keyNavigation(aEvent, this._dir);
+        }
         break;
       case "mousemove":
-        this._resetKeyNavigation();
+        this.openViews.forEach(panelView => panelView.clearNavigation());
         break;
       case "popupshowing": {
         this.node.setAttribute("panelopen", "true");
         if (!this.node.hasAttribute("disablekeynav")) {
           this.window.addEventListener("keydown", this);
           this._panel.addEventListener("mousemove", this);
         }
         break;
@@ -767,221 +761,33 @@ this.PanelMultiView = class extends this
           if (panelView.nodeName != "children") {
             panelView.__lastKnownBoundingRect = null;
             panelView.style.removeProperty("min-width");
             panelView.style.removeProperty("max-width");
           }
         }
         this.window.removeEventListener("keydown", this);
         this._panel.removeEventListener("mousemove", this);
-        this._resetKeyNavigation();
+        this.openViews.forEach(panelView => panelView.clearNavigation());
         this.openViews = [];
 
         // Clear the main view size caches. The dimensions could be different
         // when the popup is opened again, e.g. through touch mode sizing.
         this._mainViewHeight = 0;
         this._mainViewWidth = 0;
         this._viewContainer.style.removeProperty("min-height");
         this._viewStack.style.removeProperty("max-height");
         this._viewContainer.style.removeProperty("min-width");
         this._viewContainer.style.removeProperty("max-width");
 
         this.dispatchCustomEvent("PanelMultiViewHidden");
         break;
       }
     }
   }
-
-  /**
-   * Based on going up or down, select the previous or next focusable button
-   * in the current view.
-   *
-   * @param {Object}  navMap   the navigation keyboard map object for the view
-   * @param {Array}   buttons  an array of focusable buttons to select an item from.
-   * @param {Boolean} isDown   whether we're going down (true) or up (false) in this view.
-   *
-   * @return {DOMNode} the button we selected.
-   */
-  _updateSelectedKeyNav(navMap, buttons, isDown) {
-    let lastSelected = navMap.selected && navMap.selected.get();
-    let newButton = null;
-    let maxIdx = buttons.length - 1;
-    if (lastSelected) {
-      let buttonIndex = buttons.indexOf(lastSelected);
-      if (buttonIndex != -1) {
-        // Buttons may get selected whilst the panel is shown, so add an extra
-        // check here.
-        do {
-          buttonIndex = buttonIndex + (isDown ? 1 : -1);
-        } while (buttons[buttonIndex] && buttons[buttonIndex].disabled);
-        if (isDown && buttonIndex > maxIdx)
-          buttonIndex = 0;
-        else if (!isDown && buttonIndex < 0)
-          buttonIndex = maxIdx;
-        newButton = buttons[buttonIndex];
-      } else {
-        // The previously selected item is no longer selectable. Find the next item:
-        let allButtons = lastSelected.closest("panelview").getElementsByTagName("toolbarbutton");
-        let maxAllButtonIdx = allButtons.length - 1;
-        let allButtonIndex = allButtons.indexOf(lastSelected);
-        while (allButtonIndex >= 0 && allButtonIndex <= maxAllButtonIdx) {
-          allButtonIndex++;
-          // Check if the next button is in the list of focusable buttons.
-          buttonIndex = buttons.indexOf(allButtons[allButtonIndex]);
-          if (buttonIndex != -1) {
-            // If it is, just use that button if we were going down, or the previous one
-            // otherwise. If this was the first button, newButton will end up undefined,
-            // which is fine because we'll fall back to using the last button at the
-            // bottom of this method.
-            newButton = buttons[isDown ? buttonIndex : buttonIndex - 1];
-            break;
-          }
-        }
-      }
-    }
-
-    // If we couldn't find something, select the first or last item:
-    if (!newButton) {
-      newButton = buttons[isDown ? 0 : maxIdx];
-    }
-    navMap.selected = Cu.getWeakReference(newButton);
-    return newButton;
-  }
-
-  /**
-   * Allow for navigating subview buttons using the arrow keys and the Enter key.
-   * The Up and Down keys can be used to navigate the list up and down and the
-   * Enter, Right or Left - depending on the text direction - key can be used to
-   * simulate a click on the currently selected button.
-   * The Right or Left key - depending on the text direction - can be used to
-   * navigate to the previous view, functioning as a shortcut for the view's
-   * back button.
-   * Thus, in LTR mode:
-   *  - The Right key functions the same as the Enter key, simulating a click
-   *  - The Left key triggers a navigation back to the previous view.
-   *
-   * @param {KeyEvent} event
-   */
-  _keyNavigation(event) {
-    if (this._transitioning)
-      return;
-
-    let view = this._currentSubView;
-    let navMap = this._keyNavigationMap.get(view);
-    if (!navMap) {
-      navMap = {};
-      this._keyNavigationMap.set(view, navMap);
-    }
-
-    let buttons = navMap.buttons;
-    if (!buttons || !buttons.length) {
-      buttons = navMap.buttons = PanelView.forNode(view).getNavigableElements();
-      // Set the 'tabindex' attribute on the buttons to make sure they're focussable.
-      for (let button of buttons) {
-        if (!button.classList.contains("subviewbutton-back") &&
-            !button.hasAttribute("tabindex")) {
-          button.setAttribute("tabindex", 0);
-        }
-      }
-    }
-    if (!buttons.length)
-      return;
-
-    let stop = () => {
-      event.stopPropagation();
-      event.preventDefault();
-    };
-
-    let keyCode = event.code;
-    switch (keyCode) {
-      case "ArrowDown":
-      case "ArrowUp":
-      case "Tab": {
-        stop();
-        let isDown = (keyCode == "ArrowDown") ||
-                     (keyCode == "Tab" && !event.shiftKey);
-        let button = this._updateSelectedKeyNav(navMap, buttons, isDown);
-        button.focus();
-        break;
-      }
-      case "ArrowLeft":
-      case "ArrowRight": {
-        stop();
-        let dir = this._dir;
-        if ((dir == "ltr" && keyCode == "ArrowLeft") ||
-            (dir == "rtl" && keyCode == "ArrowRight")) {
-          if (this._canGoBack(view))
-            this.goBack();
-          break;
-        }
-        // If the current button is _not_ one that points to a subview, pressing
-        // the arrow key shouldn't do anything.
-        if (!navMap.selected || !navMap.selected.get() ||
-            !navMap.selected.get().classList.contains("subviewbutton-nav")) {
-          break;
-        }
-        // Fall-through...
-      }
-      case "Space":
-      case "Enter": {
-        let button = navMap.selected && navMap.selected.get();
-        if (!button)
-          break;
-        stop();
-
-        // Unfortunately, 'tabindex' doesn't execute the default action, so
-        // we explicitly do this here.
-        // We are sending a command event and then a click event.
-        // This is done in order to mimic a "real" mouse click event.
-        // The command event executes the action, then the click event closes the menu.
-        button.doCommand();
-        let clickEvent = new event.target.ownerGlobal.MouseEvent("click", {"bubbles": true});
-        button.dispatchEvent(clickEvent);
-        break;
-      }
-    }
-  }
-
-  /**
-   * Clear all traces of keyboard navigation happening right now.
-   *
-   * @param {panelview} view View to reset the key navigation attributes of.
-   *                         If no view is passed, all navigation attributes for
-   *                         this panelmultiview are cleared.
-   */
-  _resetKeyNavigation(view) {
-    let viewToBlur = view || this._currentSubView;
-    let navMap = this._keyNavigationMap.get(viewToBlur);
-    if (navMap && navMap.selected && navMap.selected.get()) {
-      navMap.selected.get().blur();
-    }
-
-    // We clear the entire key navigation map ONLY if *no* view was passed in.
-    // This happens e.g. when the popup is hidden completely, or the user moves
-    // their mouse.
-    // If a view is passed in, we just delete the map for that view. This happens
-    // when going back from a view (which resets the map for that view only)
-    if (view) {
-      this._keyNavigationMap.delete(view);
-    } else {
-      this._keyNavigationMap.clear();
-    }
-  }
-
-  /**
-   * Focus the last selected element in the view, if any.
-   *
-   * @param {panelview} view the view in which to update keyboard focus.
-   */
-  _updateKeyboardFocus(view) {
-    let navMap = this._keyNavigationMap.get(view);
-    if (navMap && navMap.selected && navMap.selected.get()) {
-      navMap.selected.get().focus();
-    }
-  }
 };
 
 /**
  * This is associated to <panelview> elements.
  */
 this.PanelView = class extends this.AssociatedToNode {
   /**
    * The "mainview" attribute is set before the panel is opened when this view
@@ -1139,9 +945,188 @@ this.PanelView = class extends this.Asso
   getNavigableElements() {
     let buttons = Array.from(this.node.querySelectorAll(".subviewbutton:not([disabled])"));
     let dwu = this._dwu;
     return buttons.filter(button => {
       let bounds = dwu.getBoundsWithoutFlushing(button);
       return bounds.width > 0 && bounds.height > 0;
     });
   }
+
+  /**
+   * Element that is currently selected with the keyboard, or null if no element
+   * is selected. Since the reference is held weakly, it can become null or
+   * undefined at any time.
+   *
+   * The element is usually, but not necessarily, in the "buttons" property
+   * which in turn is initialized from the getNavigableElements list.
+   */
+  get selectedElement() {
+    return this._selectedElement && this._selectedElement.get();
+  }
+  set selectedElement(value) {
+    if (!value) {
+      delete this._selectedElement;
+    } else {
+      this._selectedElement = Cu.getWeakReference(value);
+    }
+  }
+
+  /**
+   * Based on going up or down, select the previous or next focusable button.
+   *
+   * @param {Boolean} isDown   whether we're going down (true) or up (false).
+   *
+   * @return {DOMNode} the button we selected.
+   */
+  moveSelection(isDown) {
+    let buttons = this.buttons;
+    let lastSelected = this.selectedElement;
+    let newButton = null;
+    let maxIdx = buttons.length - 1;
+    if (lastSelected) {
+      let buttonIndex = buttons.indexOf(lastSelected);
+      if (buttonIndex != -1) {
+        // Buttons may get selected whilst the panel is shown, so add an extra
+        // check here.
+        do {
+          buttonIndex = buttonIndex + (isDown ? 1 : -1);
+        } while (buttons[buttonIndex] && buttons[buttonIndex].disabled);
+        if (isDown && buttonIndex > maxIdx)
+          buttonIndex = 0;
+        else if (!isDown && buttonIndex < 0)
+          buttonIndex = maxIdx;
+        newButton = buttons[buttonIndex];
+      } else {
+        // The previously selected item is no longer selectable. Find the next item:
+        let allButtons = lastSelected.closest("panelview").getElementsByTagName("toolbarbutton");
+        let maxAllButtonIdx = allButtons.length - 1;
+        let allButtonIndex = allButtons.indexOf(lastSelected);
+        while (allButtonIndex >= 0 && allButtonIndex <= maxAllButtonIdx) {
+          allButtonIndex++;
+          // Check if the next button is in the list of focusable buttons.
+          buttonIndex = buttons.indexOf(allButtons[allButtonIndex]);
+          if (buttonIndex != -1) {
+            // If it is, just use that button if we were going down, or the previous one
+            // otherwise. If this was the first button, newButton will end up undefined,
+            // which is fine because we'll fall back to using the last button at the
+            // bottom of this method.
+            newButton = buttons[isDown ? buttonIndex : buttonIndex - 1];
+            break;
+          }
+        }
+      }
+    }
+
+    // If we couldn't find something, select the first or last item:
+    if (!newButton) {
+      newButton = buttons[isDown ? 0 : maxIdx];
+    }
+    this.selectedElement = newButton;
+    return newButton;
+  }
+
+  /**
+   * Allow for navigating subview buttons using the arrow keys and the Enter key.
+   * The Up and Down keys can be used to navigate the list up and down and the
+   * Enter, Right or Left - depending on the text direction - key can be used to
+   * simulate a click on the currently selected button.
+   * The Right or Left key - depending on the text direction - can be used to
+   * navigate to the previous view, functioning as a shortcut for the view's
+   * back button.
+   * Thus, in LTR mode:
+   *  - The Right key functions the same as the Enter key, simulating a click
+   *  - The Left key triggers a navigation back to the previous view.
+   *
+   * @param {KeyEvent} event
+   * @param {String} dir
+   *        Direction for arrow navigation, either "ltr" or "rtl".
+   */
+  keyNavigation(event, dir) {
+    let buttons = this.buttons;
+    if (!buttons || !buttons.length) {
+      buttons = this.buttons = this.getNavigableElements();
+      // Set the 'tabindex' attribute on the buttons to make sure they're focussable.
+      for (let button of buttons) {
+        if (!button.classList.contains("subviewbutton-back") &&
+            !button.hasAttribute("tabindex")) {
+          button.setAttribute("tabindex", 0);
+        }
+      }
+    }
+    if (!buttons.length)
+      return;
+
+    let stop = () => {
+      event.stopPropagation();
+      event.preventDefault();
+    };
+
+    let keyCode = event.code;
+    switch (keyCode) {
+      case "ArrowDown":
+      case "ArrowUp":
+      case "Tab": {
+        stop();
+        let isDown = (keyCode == "ArrowDown") ||
+                     (keyCode == "Tab" && !event.shiftKey);
+        let button = this.moveSelection(isDown);
+        button.focus();
+        break;
+      }
+      case "ArrowLeft":
+      case "ArrowRight": {
+        stop();
+        if ((dir == "ltr" && keyCode == "ArrowLeft") ||
+            (dir == "rtl" && keyCode == "ArrowRight")) {
+          this.node.panelMultiView.goBack();
+          break;
+        }
+        // If the current button is _not_ one that points to a subview, pressing
+        // the arrow key shouldn't do anything.
+        let button = this.selectedElement;
+        if (!button || !button.classList.contains("subviewbutton-nav")) {
+          break;
+        }
+        // Fall-through...
+      }
+      case "Space":
+      case "Enter": {
+        let button = this.selectedElement;
+        if (!button)
+          break;
+        stop();
+
+        // Unfortunately, 'tabindex' doesn't execute the default action, so
+        // we explicitly do this here.
+        // We are sending a command event and then a click event.
+        // This is done in order to mimic a "real" mouse click event.
+        // The command event executes the action, then the click event closes the menu.
+        button.doCommand();
+        let clickEvent = new event.target.ownerGlobal.MouseEvent("click", {"bubbles": true});
+        button.dispatchEvent(clickEvent);
+        break;
+      }
+    }
+  }
+
+  /**
+   * Focus the last selected element in the view, if any.
+   */
+  focusSelectedElement() {
+    let selected = this.selectedElement;
+    if (selected) {
+      selected.focus();
+    }
+  }
+
+  /**
+   * Clear all traces of keyboard navigation happening right now.
+   */
+  clearNavigation() {
+    delete this.buttons;
+    let selected = this.selectedElement;
+    if (selected) {
+      selected.blur();
+      this.selectedElement = null;
+    }
+  }
 };