diff options
Diffstat (limited to 'browser/base/content/browser-toolbarKeyNav.js')
-rw-r--r-- | browser/base/content/browser-toolbarKeyNav.js | 435 |
1 files changed, 435 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..caa01100c5 --- /dev/null +++ b/browser/base/content/browser-toolbarKeyNav.js @@ -0,0 +1,435 @@ +/* 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) { + if (aElem.getAttribute("keyNav") === "false") { + return false; + } + 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; + } + }, +}; |