summaryrefslogtreecommitdiffstats
path: root/browser/base/content/browser-toolbarKeyNav.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/base/content/browser-toolbarKeyNav.js')
-rw-r--r--browser/base/content/browser-toolbarKeyNav.js433
1 files changed, 433 insertions, 0 deletions
diff --git a/browser/base/content/browser-toolbarKeyNav.js b/browser/base/content/browser-toolbarKeyNav.js
new file mode 100644
index 0000000000..497f54dedb
--- /dev/null
+++ b/browser/base/content/browser-toolbarKeyNav.js
@@ -0,0 +1,433 @@
+/* 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.
+ * In addition to linear navigation with tab and arrows, users can also type
+ * the first (or first few) characters of a button's name to jump directly to
+ * that button.
+ */
+
+ToolbarKeyboardNavigator = {
+ // Toolbars we want to be keyboard navigable.
+ kToolbars: [
+ CustomizableUI.AREA_TABSTRIP,
+ CustomizableUI.AREA_NAVBAR,
+ CustomizableUI.AREA_BOOKMARKS,
+ ],
+ // Delay (in ms) after which to clear any search text typed by the user if
+ // the user hasn't typed anything further.
+ kSearchClearTimeout: 1000,
+
+ _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 disabled elements.
+ if (aNode.disabled) {
+ return NodeFilter.FILTER_REJECT;
+ }
+
+ // Skip invisible elements.
+ const visible = aNode.checkVisibility({
+ checkVisibilityCSS: true,
+ flush: false,
+ });
+ if (!visible) {
+ return NodeFilter.FILTER_REJECT;
+ }
+
+ // This width check excludes the overflow button when there's no overflow.
+ const bounds = window.windowUtils.getBoundsWithoutFlushing(aNode);
+ if (bounds.width == 0) {
+ return NodeFilter.FILTER_SKIP;
+ }
+
+ if (this._isButton(aNode)) {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ };
+ aRoot._toolbarKeyNavWalker = document.createTreeWalker(
+ aRoot,
+ NodeFilter.SHOW_ELEMENT,
+ filter
+ );
+ return aRoot._toolbarKeyNavWalker;
+ },
+
+ _initTabStops(aRoot) {
+ for (let stop of aRoot.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);
+ }
+ },
+
+ 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");
+ this._initTabStops(toolbar);
+ toolbar.addEventListener("keydown", this);
+ toolbar.addEventListener("keypress", this);
+ }
+ CustomizableUI.addListener(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");
+ }
+ CustomizableUI.removeListener(this);
+ },
+
+ // CustomizableUI event handler
+ onWidgetAdded(aWidgetId, aArea, aPosition) {
+ if (!this.kToolbars.includes(aArea)) {
+ return;
+ }
+ let widget = document.getElementById(aWidgetId);
+ if (!widget) {
+ return;
+ }
+ this._initTabStops(widget);
+ },
+
+ _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)) {
+ // If we think we're moving backward, and focus came from outside the
+ // toolbox, we might actually have wrapped around. In this case, the
+ // event target was the first tabstop. If we can't find a button, e.g.
+ // because we're in a popup where most buttons are hidden, we
+ // should ensure focus keeps moving forward:
+ if (
+ this._isFocusMovingBackward &&
+ (!oldFocus || !gNavToolbox.contains(oldFocus))
+ ) {
+ let allStops = Array.from(
+ gNavToolbox.querySelectorAll("toolbartabstop")
+ );
+ // Find the previous toolbartabstop:
+ let earlierVisibleStopIndex = allStops.indexOf(aEvent.target) - 1;
+ // Then work out if any of the earlier ones are in a visible
+ // toolbar:
+ while (earlierVisibleStopIndex >= 0) {
+ let stopToolbar = allStops[earlierVisibleStopIndex].closest(
+ "toolbar"
+ );
+ if (!stopToolbar.collapsed) {
+ break;
+ }
+ earlierVisibleStopIndex--;
+ }
+ // If we couldn't find any earlier visible stops, we're not moving
+ // backwards, we're moving forwards and wrapped around:
+ if (earlierVisibleStopIndex == -1) {
+ this._isFocusMovingBackward = false;
+ }
+ }
+ // 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.key != " " &&
+ aEvent.key.length == 1 &&
+ this._isButton(focus) &&
+ // Don't handle characters if the user is focused in a panel anchored
+ // to the toolbar.
+ !focus.closest("panel")
+ ) {
+ this._onSearchChar(aEvent.currentTarget, aEvent.key);
+ return;
+ }
+ // Anything that doesn't trigger search should clear the search.
+ this._clearSearch();
+
+ 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();
+ },
+
+ _clearSearch() {
+ this._searchText = "";
+ if (this._clearSearchTimeout) {
+ clearTimeout(this._clearSearchTimeout);
+ this._clearSearchTimeout = null;
+ }
+ },
+
+ _onSearchChar(aToolbar, aChar) {
+ if (this._clearSearchTimeout) {
+ // The user just typed a character, so reset the timer.
+ clearTimeout(this._clearSearchTimeout);
+ }
+ // Convert to lower case so we can do case insensitive searches.
+ let char = aChar.toLowerCase();
+ // If the user has only typed a single character and they type the same
+ // character again, they want to move to the next item starting with that
+ // same character. Effectively, it's as if there was no existing search.
+ // In that case, we just leave this._searchText alone.
+ if (!this._searchText) {
+ this._searchText = char;
+ } else if (this._searchText != char) {
+ this._searchText += char;
+ }
+ // Clear the search if the user doesn't type anything more within the timeout.
+ this._clearSearchTimeout = setTimeout(
+ this._clearSearch.bind(this),
+ this.kSearchClearTimeout
+ );
+
+ let oldFocus = document.activeElement;
+ let walker = this._getWalker(aToolbar);
+ // Search forward after the current control.
+ walker.currentNode = oldFocus;
+ for (
+ let newFocus = walker.nextNode();
+ newFocus;
+ newFocus = walker.nextNode()
+ ) {
+ if (this._doesSearchMatch(newFocus)) {
+ this._focusButton(newFocus);
+ return;
+ }
+ }
+ // No match, so search from the start until the current control.
+ walker.currentNode = walker.root;
+ for (
+ let newFocus = walker.firstChild();
+ newFocus && newFocus != oldFocus;
+ newFocus = walker.nextNode()
+ ) {
+ if (this._doesSearchMatch(newFocus)) {
+ this._focusButton(newFocus);
+ return;
+ }
+ }
+ },
+
+ _doesSearchMatch(aElem) {
+ if (!this._isButton(aElem)) {
+ return false;
+ }
+ for (let attrib of ["aria-label", "label", "tooltiptext"]) {
+ let label = aElem.getAttribute(attrib);
+ if (!label) {
+ continue;
+ }
+ // Convert to lower case so we do a case insensitive comparison.
+ // (this._searchText is already lower case.)
+ label = label.toLowerCase();
+ if (label.startsWith(this._searchText)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ _onKeyPress(aEvent) {
+ let focus = document.activeElement;
+ if (
+ (aEvent.key != "Enter" && aEvent.key != " ") ||
+ !this._isButton(focus)
+ ) {
+ return;
+ }
+
+ if (focus.getAttribute("type") == "menu") {
+ focus.open = true;
+ return;
+ }
+
+ // Several buttons specifically don't use command events; e.g. because
+ // they want to activate for middle click. Therefore, simulate a click
+ // event if we know they handle click explicitly and don't handle
+ // commands.
+ const usesClickInsteadOfCommand = (() => {
+ if (focus.tagName != "toolbarbutton") {
+ return true;
+ }
+ return !focus.hasAttribute("oncommand") && focus.hasAttribute("onclick");
+ })();
+
+ if (!usesClickInsteadOfCommand) {
+ return;
+ }
+ focus.dispatchEvent(
+ new MouseEvent("click", {
+ bubbles: true,
+ ctrlKey: aEvent.ctrlKey,
+ altKey: aEvent.altKey,
+ shiftKey: aEvent.shiftKey,
+ metaKey: aEvent.metaKey,
+ })
+ );
+ },
+
+ 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;
+ }
+ },
+};