Bug 1354144 - add support for keyboard navigation inside panel views. r?Gijs
MozReview-Commit-ID: GVMyXroGmAn
--- 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. */
}