Bug 1354146 - add support for keyboard navigation inside panel views. r?Gijs draft
authorMike de Boer <mdeboer@mozilla.com>
Sun, 14 May 2017 16:20:21 +0200
changeset 577478 edade7e143582024d8aac9bd5c6b346cabbe423c
parent 577111 5f3dde8ed047bcb7793ef0a2d9b8043686de5b9f
child 628514 90966a6b1c13e292d49c861ab479b3315ba39c2e
push id58704
push usermdeboer@mozilla.com
push dateSun, 14 May 2017 14:23:25 +0000
reviewersGijs
bugs1354146
milestone55.0a1
Bug 1354146 - add support for keyboard navigation inside panel views. r?Gijs MozReview-Commit-ID: JQ2uezgfZOH
browser/components/customizableui/PanelMultiView.jsm
browser/components/customizableui/content/panelUI.xml
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -205,30 +205,40 @@ this.PanelMultiView = class {
 
     if (this._panelViews)
       return this._panelViews;
 
     this._panelViews = new SlidingPanelViews();
     this._panelViews.push(...this.node.getElementsByTagName("panelview"));
     return this._panelViews;
   }
+  get _du() {
+    if (this.__du)
+      return this.__du;
+    return this.__du = Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
+  }
   get _dwu() {
     return this.window.QueryInterface(Ci.nsIInterfaceRequestor)
                       .getInterface(Ci.nsIDOMWindowUtils);
   }
   get _currentSubView() {
     return this.panelViews ? this.panelViews.currentView : this.__currentSubView;
   }
   set _currentSubView(panel) {
     if (this.panelViews)
       this.panelViews.currentView = panel;
     else
       this.__currentSubView = panel;
     return panel;
   }
+  get _keyNavigationMap() {
+    if (!this.__keyNavigationMap)
+      this.__keyNavigationMap = new Map();
+    return this.__keyNavigationMap;
+  }
 
   constructor(xulNode) {
     this.node = xulNode;
 
     this._currentSubView = this._anchorElement = this._subViewObserver = null;
     this._mainViewHeight = 0;
     this.__transitioning = this._ignoreMutations = false;
 
@@ -635,16 +645,22 @@ this.PanelMultiView = class {
       return;
     }
     switch (aEvent.type) {
       case "click":
         if (aEvent.originalTarget == this._clickCapturer) {
           this.showMainView();
         }
         break;
+      case "keydown":
+        this._keyNavigation(aEvent);
+        break;
+      case "mousemove":
+        this._resetKeyNavigation();
+        break;
       case "overflow":
         if (!this.panelViews && aEvent.target.localName == "vbox") {
           // Resize the right view on the next tick.
           if (this.showingSubView) {
             this.window.setTimeout(this._syncContainerWithSubView.bind(this), 0);
           } else if (!this.transitioning) {
             this.window.setTimeout(this._syncContainerWithMainView.bind(this), 0);
           }
@@ -662,31 +678,158 @@ this.PanelMultiView = class {
         if (!this.panelViews) {
           this._syncContainerWithMainView();
           this._mainViewObserver.observe(this._mainView, {
             attributes: true,
             characterData: true,
             childList: true,
             subtree: true
           });
+        } else {
+          this.window.addEventListener("keydown", this);
+          this._panel.addEventListener("mousemove", this);
         }
         break;
       case "popupshown":
         this._setMaxHeight();
         break;
       case "popuphidden":
         this.node.removeAttribute("panelopen");
         this._mainView.style.removeProperty("height");
         this.showMainView();
-        if (!this.panelViews)
+        if (!this.panelViews) {
           this._mainViewObserver.disconnect();
+        } else {
+          this.window.removeEventListener("keydown", this);
+          this._panel.removeEventListener("mousemove", this);
+          this._resetKeyNavigation();
+        }
         break;
     }
   }
 
+  /**
+   * 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) {
+    let view = this._currentSubView;
+    let navMap = this._keyNavigationMap.get(view);
+    if (!navMap) {
+      navMap = {};
+      this._keyNavigationMap.set(view, {});
+    }
+    let buttons = this._getNavigableElements(view);
+    if (!buttons.length)
+      return;
+
+    let stop = () => {
+      event.stopPropagation();
+      event.preventDefault();
+    };
+    let du = this._du;
+    let idx;
+
+    switch (event.keyCode) {
+      case event.DOM_VK_DOWN:
+      case event.DOM_VK_UP: {
+        let isDown = (event.keyCode == event.DOM_VK_DOWN);
+        stop();
+        let maxIdx = buttons.length - 1;
+        idx = isDown ? 0 : maxIdx;
+        let prev = -1;
+        if (typeof navMap.selected == "number") {
+          prev = navMap.selected;
+          if (isDown) {
+            idx = ++navMap.selected;
+            if (idx > maxIdx)
+              idx = 0;
+          } else {
+            idx = --navMap.selected;
+            if (idx < 0)
+              idx = maxIdx;
+          }
+        }
+        if (prev > -1)
+          du.removePseudoClassLock(buttons[prev], ":hover");
+        du.addPseudoClassLock(buttons[idx], ":hover");
+        navMap.selected = idx;
+        break;
+      }
+      case event.DOM_VK_RIGHT:
+      case event.DOM_VK_LEFT: {
+        let dir = this._dir;
+        if ((dir == "rtl" && event.keyCode == event.DOM_VK_RIGHT) ||
+            (dir == "ltr" && event.keyCode == event.DOM_VK_LEFT)) {
+          let canGoBack = !!view.getAttribute("title");
+          if (canGoBack)
+            this.goBack(view.backButton);
+          break;
+        }
+        // Fall-through...
+      }
+      case event.DOM_VK_RETURN: {
+        idx = navMap.selected;
+        if (!buttons[idx])
+          break;
+        stop();
+        du.removePseudoClassLock(buttons[idx], ":hover");
+        delete navMap.selected;
+        buttons[idx].click();
+        break;
+      }
+    }
+  }
+
+  /**
+   * Clear all traces of keyboard navigation happening right now.
+   */
+  _resetKeyNavigation() {
+    let view = this._currentSubView;
+    let navMap = this._keyNavigationMap.get(view);
+    this._keyNavigationMap.clear();
+    if (!navMap)
+      return;
+
+    let buttons = this._getNavigableElements(view);
+    if (!buttons.length)
+      return;
+
+    let idx = navMap.selected;
+    if (!buttons[idx])
+      return;
+    this._du.removePseudoClassLock(buttons[idx], ":hover");
+    this._keyNavigationMap.clear();
+  }
+
+  /**
+   * Retrieve the button elements from a view node that can be used for navigation
+   * using the keyboard; enabled buttons and the back button, if visible.
+   *
+   * @param  {nsIDOMNode} view
+   * @return {Array}
+   */
+  _getNavigableElements(view) {
+    let buttons = Array.from(view.querySelectorAll(".subviewbutton:not([disabled])"));
+    let canGoBack = !!view.getAttribute("title");
+    if (canGoBack)
+      buttons.unshift(view.backButton);
+    return buttons;
+  }
+
   _shouldSetPosition() {
     return this.node.getAttribute("nosubviews") == "true";
   }
 
   _shouldSetHeight() {
     return this.node.getAttribute("nosubviews") != "true";
   }
 
--- a/browser/components/customizableui/content/panelUI.xml
+++ b/browser/components/customizableui/content/panelUI.xml
@@ -59,28 +59,32 @@
         </xul:stack>
       </xul:box>
     </content>
   </binding>
 
   <binding id="panelview">
     <content>
       <xul:box class="panel-header" anonid="header">
-        <xul:toolbarbutton class="subviewbutton subviewbutton-iconic subviewbutton-back"
+        <xul:toolbarbutton anonid="back"
+                           class="subviewbutton subviewbutton-iconic subviewbutton-back"
                            closemenu="none"
                            tooltip="&backCmd.label;"
                            onclick="document.getBindingParent(this).panelMultiView.goBack()"/>
         <xul:label xbl:inherits="value=title"/>
       </xul:box>
       <children/>
     </content>
     <implementation>
       <property name="header"
                 readonly="true"
                 onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'header');"/>
+      <property name="backButton"
+                readonly="true"
+                onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'back');"/>
       <property name="panelMultiView" readonly="true">
         <getter><![CDATA[
           if (!this.parentNode.localName.endsWith("panelmultiview")) {
             return document.getBindingParent(this.parentNode);
           }
 
           return this.parentNode;
         ]]></getter>