fune/browser/base/content/browser-toolbarKeyNav.js
James Teh 8810a3c233 Bug 1540918: When a panel is opened from the toolbar via the keyboard, restore focus to the toolbar button when the panel is closed. r=Gijs
Toolbar buttons are only made focusable when accessed via the keyboard.
As soon as they lose focus, their focusability is removed.
This is done so that clicking them doesn't focus them.
Previously, this meant that if a panel opened, focus couldn't be restored to the button like it should be when the panel was closed, as the button was no longer focusable.
To work around this, use the open="true" attribute set on the button when the panel opens.
If this is detected when the button loses focus, focusability is not removed.
Once focus is restored after the panel closes, things continue as normal.
That is, when the button loses focus for some other reason, its focusability is removed.

Differential Revision: https://phabricator.services.mozilla.com/D26073

--HG--
extra : moz-landing-system : lando
2019-04-04 17:04:18 +00:00

255 lines
8.8 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// This file is loaded into the browser window scope.
/* eslint-env mozilla/browser-window */
/**
* Handle keyboard navigation for toolbars.
* Having separate tab stops for every toolbar control results in an
* unmanageable number of tab stops. Therefore, we group buttons under a single
* tab stop and allow movement between them using left/right arrows.
* However, text inputs use the arrow keys for their own purposes, so they need
* their own tab stop. There are also groups of buttons before and after the
* URL bar input which should get their own tab stop. The subsequent buttons on
* the toolbar are then another tab stop after that.
* Tab stops for groups of buttons are set using the <toolbartabstop/> element.
* This element is invisible, but gets included in the tab order. When one of
* these gets focus, it redirects focus to the appropriate button. This avoids
* the need to continually manage the tabindex of toolbar buttons in response to
* toolbarchanges.
*/
ToolbarKeyboardNavigator = {
// Toolbars we want to be keyboard navigable.
kToolbars: [CustomizableUI.AREA_NAVBAR, CustomizableUI.AREA_BOOKMARKS],
_isButton(aElem) {
return aElem.tagName == "toolbarbutton" ||
aElem.getAttribute("role") == "button";
},
// Get a TreeWalker which includes only controls which should be keyboard
// navigable.
_getWalker(aRoot) {
if (aRoot._toolbarKeyNavWalker) {
return aRoot._toolbarKeyNavWalker;
}
let filter = (aNode) => {
if (aNode.tagName == "toolbartabstop") {
return NodeFilter.FILTER_ACCEPT;
}
// Special case for the "View site information" button, which isn't
// actionable in some cases but is still visible.
if (aNode.id == "identity-box" &&
document.getElementById("urlbar").getAttribute("pageproxystate") ==
"invalid") {
return NodeFilter.FILTER_REJECT;
}
// Skip invisible or disabled elements.
if (aNode.hidden || aNode.disabled
|| aNode.style.visibility == "hidden") {
return NodeFilter.FILTER_REJECT;
}
// This width check excludes the overflow button when there's no overflow.
let bounds = window.windowUtils.getBoundsWithoutFlushing(aNode);
if (bounds.width == 0) {
return NodeFilter.FILTER_REJECT;
}
if (this._isButton(aNode)) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
};
aRoot._toolbarKeyNavWalker = document.createTreeWalker(aRoot,
NodeFilter.SHOW_ELEMENT, filter);
return aRoot._toolbarKeyNavWalker;
},
init() {
for (let id of this.kToolbars) {
let toolbar = document.getElementById(id);
// When enabled, no toolbar buttons should themselves be tabbable.
// We manage toolbar focus completely. This attribute ensures that CSS
// doesn't set -moz-user-focus: normal.
toolbar.setAttribute("keyNav", "true");
for (let stop of toolbar.getElementsByTagName("toolbartabstop")) {
// These are invisible, but because they need to be in the tab order,
// they can't get display: none or similar. They must therefore be
// explicitly hidden for accessibility.
stop.setAttribute("aria-hidden", "true");
stop.addEventListener("focus", this);
}
toolbar.addEventListener("keydown", this);
toolbar.addEventListener("keypress", this);
}
},
uninit() {
for (let id of this.kToolbars) {
let toolbar = document.getElementById(id);
for (let stop of toolbar.getElementsByTagName("toolbartabstop")) {
stop.removeEventListener("focus", this);
}
toolbar.removeEventListener("keydown", this);
toolbar.removeEventListener("keypress", this);
toolbar.removeAttribute("keyNav");
}
},
_focusButton(aButton) {
// Toolbar buttons aren't focusable because if they were, clicking them
// would focus them, which is undesirable. Therefore, we must make a
// button focusable only when we want to focus it.
aButton.setAttribute("tabindex", "-1");
aButton.focus();
// We could remove tabindex now, but even though the button keeps DOM
// focus, a11y gets confused because the button reports as not being
// focusable. This results in weirdness if the user switches windows and
// then switches back. It also means that focus can't be restored to the
// button when a panel is closed. Instead, remove tabindex when the button
// loses focus.
aButton.addEventListener("blur", this);
},
_onButtonBlur(aEvent) {
if (document.activeElement == aEvent.target) {
// This event was fired because the user switched windows. This button
// will get focus again when the user returns.
return;
}
if (aEvent.target.getAttribute("open") == "true") {
// The button activated a panel. The button should remain
// focusable so that focus can be restored when the panel closes.
return;
}
aEvent.target.removeEventListener("blur", this);
aEvent.target.removeAttribute("tabindex");
},
_onTabStopFocus(aEvent) {
let toolbar = aEvent.target.closest("toolbar");
let walker = this._getWalker(toolbar);
let oldFocus = aEvent.relatedTarget;
if (oldFocus) {
// Save this because we might rewind focus and the subsequent focus event
// won't get a relatedTarget.
this._isFocusMovingBackward =
oldFocus.compareDocumentPosition(aEvent.target) &
Node.DOCUMENT_POSITION_PRECEDING;
if (this._isFocusMovingBackward && oldFocus && this._isButton(oldFocus)) {
// Shift+tabbing from a button will land on its toolbartabstop. Skip it.
document.commandDispatcher.rewindFocus();
return;
}
}
walker.currentNode = aEvent.target;
let button = walker.nextNode();
if (!button || !this._isButton(button)) {
// No navigable buttons for this tab stop. Skip it.
if (this._isFocusMovingBackward) {
document.commandDispatcher.rewindFocus();
} else {
document.commandDispatcher.advanceFocus();
}
return;
}
this._focusButton(button);
},
navigateButtons(aToolbar, aPrevious) {
let oldFocus = document.activeElement;
let walker = this._getWalker(aToolbar);
// Start from the current control and walk to the next/previous control.
walker.currentNode = oldFocus;
let newFocus;
if (aPrevious) {
newFocus = walker.previousNode();
} else {
newFocus = walker.nextNode();
}
if (!newFocus || newFocus.tagName == "toolbartabstop") {
// There are no more controls or we hit a tab stop placeholder.
return;
}
this._focusButton(newFocus);
},
_onKeyDown(aEvent) {
let focus = document.activeElement;
if (aEvent.altKey || aEvent.controlKey || aEvent.metaKey ||
aEvent.shiftKey || !this._isButton(focus)) {
return;
}
switch (aEvent.key) {
case "ArrowLeft":
// Previous if UI is LTR, next if UI is RTL.
this.navigateButtons(aEvent.currentTarget, !window.RTL_UI);
break;
case "ArrowRight":
// Previous if UI is RTL, next if UI is LTR.
this.navigateButtons(aEvent.currentTarget, window.RTL_UI);
break;
default:
return;
}
aEvent.preventDefault();
},
_onKeyPress(aEvent) {
let focus = document.activeElement;
if ((aEvent.key != "Enter" && aEvent.key != " ") ||
!this._isButton(focus)) {
return;
}
if (focus.getAttribute("type") == "menu") {
focus.open = true;
} else {
// Several buttons specifically don't use command events; e.g. because
// they want to activate for middle click. Therefore, simulate a
// click event.
// If this button does handle command events, that won't trigger here.
// Command events have their own keyboard handling: keypress for enter
// and keyup for space. We rely on that behavior, since there's no way
// for us to reliably know what events a button handles.
focus.dispatchEvent(new MouseEvent("click", {
bubbles: true,
ctrlKey: aEvent.ctrlKey,
altKey: aEvent.altKey,
shiftKey: aEvent.shiftKey,
metaKey: aEvent.metaKey,
}));
}
// We deliberately don't call aEvent.preventDefault() here so that enter
// will trigger a command event handler if appropriate.
aEvent.stopPropagation();
},
handleEvent(aEvent) {
switch (aEvent.type) {
case "focus":
this._onTabStopFocus(aEvent);
break;
case "keydown":
this._onKeyDown(aEvent);
break;
case "keypress":
this._onKeyPress(aEvent);
break;
case "blur":
this._onButtonBlur(aEvent);
break;
}
},
};