/* 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 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) { 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; } }, };