Bug 1354144 - add support for keyboard navigation inside panel views. r?Gijs draft
authorMike de Boer <mdeboer@mozilla.com>
Mon, 22 May 2017 12:53:43 +0200
changeset 582371 b808fb92425d8bb4491452bbc6643b23711f8b13
parent 582257 9851fcb0bf4d855c36729d7de19f0fa5c9f69776
child 629750 92b94bc03096e4c5f474778d3223e643251fc60b
push id60060
push usermdeboer@mozilla.com
push dateMon, 22 May 2017 10:56:42 +0000
reviewersGijs
bugs1354144
milestone55.0a1
Bug 1354144 - add support for keyboard navigation inside panel views. r?Gijs MozReview-Commit-ID: GVMyXroGmAn
browser/components/customizableui/PanelMultiView.jsm
browser/components/customizableui/content/panelUI.xml
browser/themes/shared/customizableui/panelUI.inc.css
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -221,16 +221,21 @@ this.PanelMultiView = class {
   }
   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;
 
@@ -313,16 +318,29 @@ this.PanelMultiView = class {
       this._subViews = this._viewStack = this.__dwu = null;
   }
 
   goBack(target) {
     let [current, previous] = this.panelViews.back();
     return this.showSubView(current, target, previous);
   }
 
+  /**
+   * Checks whether it is possible to navigate backwards currently.
+   * Since the visibility of the back button is dependent - right now - on the
+   * fact that there's a view title set, we use that heuristic to determine this
+   * capability.
+   *
+   * @param  {panelview} view View to check, defaults to the currently active view.
+   * @return {Boolean}
+   */
+  _canGoBack(view = this._currentSubView) {
+    return !!view.getAttribute("title");
+  }
+
   setMainView(aNewMainView) {
     if (this.panelViews) {
       // If the new main view is not yet in the zeroth position, make sure it's
       // inserted there.
       if (aNewMainView.parentNode != this._viewStack && this._viewStack.firstChild != aNewMainView) {
         this._viewStack.insertBefore(aNewMainView, this._viewStack.firstChild);
       }
     } else {
@@ -543,16 +561,17 @@ this.PanelMultiView = class {
               window.setTimeout(() => {
                 this._viewContainer.style.removeProperty("height");
                 this._viewContainer.style.removeProperty("width");
               }, 500);
               ++seen;
             } else if (ev.target == nodeToAnimate && ev.propertyName == "transform") {
               onTransitionEnd();
               this._transitioning = false;
+              this._resetKeyNavigation(previousViewNode);
 
               // Take another breather, just like before, to wait for the 'current'
               // attribute removal to take effect. This prevents a flicker.
               // The cleanup we do doesn't affect the display anymore, so we're not
               // too fussed about the timing here.
               window.addEventListener("MozAfterPaint", () => {
                 nodeToAnimate.style.removeProperty("border-inline-start");
                 nodeToAnimate.style.removeProperty("transition");
@@ -645,16 +664,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);
           }
@@ -672,31 +697,173 @@ 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) {
+    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 = this._getNavigableElements(view);
+      // Set the 'tabindex' attribute on the buttons to make sure they're focussable.
+      for (let button of buttons) {
+        if (button.classList.contains("subviewbutton-back"))
+          continue;
+        // If we've been here before, forget about it!
+        if (button.hasAttribute("tabindex"))
+          break;
+        button.setAttribute("tabindex", 0);
+      }
+    }
+    if (!buttons.length)
+      return;
+
+    let stop = () => {
+      event.stopPropagation();
+      event.preventDefault();
+    };
+
+    let keyCode = event.code;
+    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;
+          }
+        }
+        let button = buttons[buttonIndex];
+        button.focus();
+        navMap.selected = buttonIndex;
+        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(view.backButton);
+          break;
+        }
+        // If the current button is _not_ one that points to a subview, pressing
+        // the arrow key shouldn't do anything.
+        if (!navMap.selected || !buttons[navMap.selected].classList.contains("subviewbutton-nav"))
+          break;
+        // Fall-through...
+      }
+      case "Enter": {
+        let button = buttons[navMap.selected];
+        if (!button)
+          break;
+        stop();
+        // Unfortunately, 'tabindex' doesn't not execute the default action, so
+        // we explicitly do this here.
+        button.click();
+        break;
+      }
+    }
+  }
+
+  /**
+   * Clear all traces of keyboard navigation happening right now.
+   *
+   * @param {panelview} view View to reset the key navigation attributes of.
+   *                         Defaults to `this._currentSubView`.
+   */
+  _resetKeyNavigation(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 button = buttons[navMap.selected];
+    if (button)
+      button.blur();
+  }
+
+  /**
+   * 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])"));
+    if (this._canGoBack(view))
+      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,33 @@
         </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"
+                           tabindex="0"
                            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>
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -7,17 +7,17 @@
 %define menuPanelWidth 22.35em
 %define standaloneSubviewWidth 30em
 % XXXgijs This is the ugliest bit of code I think I've ever written for Mozilla.
 % Basically, the 0.1px is there to avoid CSS rounding errors causing buttons to wrap.
 % For gory details, refer to https://bugzilla.mozilla.org/show_bug.cgi?id=963365#c11
 % There's no calc() here (and therefore lots of calc() where this is used) because
 % we don't support nested calc(): https://bugzilla.mozilla.org/show_bug.cgi?id=968761
 %define menuPanelButtonWidth (@menuPanelWidth@ / 3 - 0.1px)
-%define buttonStateHover :not(:-moz-any([disabled],[open],:active)):hover
+%define buttonStateHover :not(:-moz-any([disabled],[open],:active)):-moz-any(:hover,:focus)
 %define menuStateHover :not(:-moz-any([disabled],:active))[_moz-menuactive]
 %define buttonStateActive :not([disabled]):-moz-any([open],:hover:active)
 %define menuStateActive :not([disabled])[_moz-menuactive]:active
 %define menuStateMenuActive :not([disabled])[_moz-menuactive]
 
 %include ../browser.inc
 
 :root {
@@ -1152,16 +1152,20 @@ panelview:not([mainView]) .subviewbutton
 photonpanelmultiview .PanelUI-subView .subviewbutton:not(.panel-subview-footer) {
   border-radius: 0;
   border-width: 0;
   margin-left: 0;
   margin-right: 0;
   padding: 4px 12px;
 }
 
+photonpanelmultiview .subviewbutton:focus {
+  outline: 0;
+}
+
 photonpanelmultiview .subviewbutton-iconic:not(.subviewbutton-back) > .toolbarbutton-text,
 photonpanelmultiview .subviewbutton[checked="true"] > .toolbarbutton-text {
   padding-inline-start: 8px; /* See '.subviewbutton-iconic > .toolbarbutton-text' rule above. */
 }
 
 photonpanelmultiview .subviewbutton:not(.subviewbutton-iconic):not([checked="true"]) > .toolbarbutton-text {
   padding-inline-start: 24px; /* This is 16px for the icon + 8px for the padding as defined above. */
 }