1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 // This file is loaded into the browser window scope.
6 /* eslint-env mozilla/browser-window */
9 * Handle keyboard navigation for toolbars.
10 * Having separate tab stops for every toolbar control results in an
11 * unmanageable number of tab stops. Therefore, we group buttons under a single
12 * tab stop and allow movement between them using left/right arrows.
13 * However, text inputs use the arrow keys for their own purposes, so they need
14 * their own tab stop. There are also groups of buttons before and after the
15 * URL bar input which should get their own tab stop. The subsequent buttons on
16 * the toolbar are then another tab stop after that.
17 * Tab stops for groups of buttons are set using the <toolbartabstop/> element.
18 * This element is invisible, but gets included in the tab order. When one of
19 * these gets focus, it redirects focus to the appropriate button. This avoids
20 * the need to continually manage the tabindex of toolbar buttons in response to
22 * In addition to linear navigation with tab and arrows, users can also type
23 * the first (or first few) characters of a button's name to jump directly to
27 ToolbarKeyboardNavigator = {
28 // Toolbars we want to be keyboard navigable.
30 CustomizableUI.AREA_TABSTRIP,
31 CustomizableUI.AREA_NAVBAR,
32 CustomizableUI.AREA_BOOKMARKS,
34 // Delay (in ms) after which to clear any search text typed by the user if
35 // the user hasn't typed anything further.
36 kSearchClearTimeout: 1000,
39 if (aElem.getAttribute("keyNav") === "false") {
43 aElem.tagName == "toolbarbutton" || aElem.getAttribute("role") == "button"
47 // Get a TreeWalker which includes only controls which should be keyboard
50 if (aRoot._toolbarKeyNavWalker) {
51 return aRoot._toolbarKeyNavWalker;
54 let filter = aNode => {
55 if (aNode.tagName == "toolbartabstop") {
56 return NodeFilter.FILTER_ACCEPT;
59 // Special case for the "View site information" button, which isn't
60 // actionable in some cases but is still visible.
62 aNode.id == "identity-box" &&
63 document.getElementById("urlbar").getAttribute("pageproxystate") ==
66 return NodeFilter.FILTER_REJECT;
69 // Skip disabled elements.
71 return NodeFilter.FILTER_REJECT;
74 // Skip invisible elements.
75 const visible = aNode.checkVisibility({
76 checkVisibilityCSS: true,
80 return NodeFilter.FILTER_REJECT;
83 // This width check excludes the overflow button when there's no overflow.
84 const bounds = window.windowUtils.getBoundsWithoutFlushing(aNode);
85 if (bounds.width == 0) {
86 return NodeFilter.FILTER_SKIP;
89 if (this._isButton(aNode)) {
90 return NodeFilter.FILTER_ACCEPT;
92 return NodeFilter.FILTER_SKIP;
94 aRoot._toolbarKeyNavWalker = document.createTreeWalker(
96 NodeFilter.SHOW_ELEMENT,
99 return aRoot._toolbarKeyNavWalker;
102 _initTabStops(aRoot) {
103 for (let stop of aRoot.getElementsByTagName("toolbartabstop")) {
104 // These are invisible, but because they need to be in the tab order,
105 // they can't get display: none or similar. They must therefore be
106 // explicitly hidden for accessibility.
107 stop.setAttribute("aria-hidden", "true");
108 stop.addEventListener("focus", this);
113 for (let id of this.kToolbars) {
114 let toolbar = document.getElementById(id);
115 // When enabled, no toolbar buttons should themselves be tabbable.
116 // We manage toolbar focus completely. This attribute ensures that CSS
117 // doesn't set -moz-user-focus: normal.
118 toolbar.setAttribute("keyNav", "true");
119 this._initTabStops(toolbar);
120 toolbar.addEventListener("keydown", this);
121 toolbar.addEventListener("keypress", this);
123 CustomizableUI.addListener(this);
127 for (let id of this.kToolbars) {
128 let toolbar = document.getElementById(id);
129 for (let stop of toolbar.getElementsByTagName("toolbartabstop")) {
130 stop.removeEventListener("focus", this);
132 toolbar.removeEventListener("keydown", this);
133 toolbar.removeEventListener("keypress", this);
134 toolbar.removeAttribute("keyNav");
136 CustomizableUI.removeListener(this);
139 // CustomizableUI event handler
140 onWidgetAdded(aWidgetId, aArea) {
141 if (!this.kToolbars.includes(aArea)) {
144 let widget = document.getElementById(aWidgetId);
148 this._initTabStops(widget);
151 _focusButton(aButton) {
152 // Toolbar buttons aren't focusable because if they were, clicking them
153 // would focus them, which is undesirable. Therefore, we must make a
154 // button focusable only when we want to focus it.
155 aButton.setAttribute("tabindex", "-1");
157 // We could remove tabindex now, but even though the button keeps DOM
158 // focus, a11y gets confused because the button reports as not being
159 // focusable. This results in weirdness if the user switches windows and
160 // then switches back. It also means that focus can't be restored to the
161 // button when a panel is closed. Instead, remove tabindex when the button
163 aButton.addEventListener("blur", this);
166 _onButtonBlur(aEvent) {
167 if (document.activeElement == aEvent.target) {
168 // This event was fired because the user switched windows. This button
169 // will get focus again when the user returns.
172 if (aEvent.target.getAttribute("open") == "true") {
173 // The button activated a panel. The button should remain
174 // focusable so that focus can be restored when the panel closes.
177 aEvent.target.removeEventListener("blur", this);
178 aEvent.target.removeAttribute("tabindex");
181 _onTabStopFocus(aEvent) {
182 let toolbar = aEvent.target.closest("toolbar");
183 let walker = this._getWalker(toolbar);
185 let oldFocus = aEvent.relatedTarget;
187 // Save this because we might rewind focus and the subsequent focus event
188 // won't get a relatedTarget.
189 this._isFocusMovingBackward =
190 oldFocus.compareDocumentPosition(aEvent.target) &
191 Node.DOCUMENT_POSITION_PRECEDING;
192 if (this._isFocusMovingBackward && oldFocus && this._isButton(oldFocus)) {
193 // Shift+tabbing from a button will land on its toolbartabstop. Skip it.
194 document.commandDispatcher.rewindFocus();
199 walker.currentNode = aEvent.target;
200 let button = walker.nextNode();
201 if (!button || !this._isButton(button)) {
202 // If we think we're moving backward, and focus came from outside the
203 // toolbox, we might actually have wrapped around. In this case, the
204 // event target was the first tabstop. If we can't find a button, e.g.
205 // because we're in a popup where most buttons are hidden, we
206 // should ensure focus keeps moving forward:
208 this._isFocusMovingBackward &&
209 (!oldFocus || !gNavToolbox.contains(oldFocus))
211 let allStops = Array.from(
212 gNavToolbox.querySelectorAll("toolbartabstop")
214 // Find the previous toolbartabstop:
215 let earlierVisibleStopIndex = allStops.indexOf(aEvent.target) - 1;
216 // Then work out if any of the earlier ones are in a visible
218 while (earlierVisibleStopIndex >= 0) {
220 allStops[earlierVisibleStopIndex].closest("toolbar");
221 if (!stopToolbar.collapsed) {
224 earlierVisibleStopIndex--;
226 // If we couldn't find any earlier visible stops, we're not moving
227 // backwards, we're moving forwards and wrapped around:
228 if (earlierVisibleStopIndex == -1) {
229 this._isFocusMovingBackward = false;
232 // No navigable buttons for this tab stop. Skip it.
233 if (this._isFocusMovingBackward) {
234 document.commandDispatcher.rewindFocus();
236 document.commandDispatcher.advanceFocus();
241 this._focusButton(button);
244 navigateButtons(aToolbar, aPrevious) {
245 let oldFocus = document.activeElement;
246 let walker = this._getWalker(aToolbar);
247 // Start from the current control and walk to the next/previous control.
248 walker.currentNode = oldFocus;
251 newFocus = walker.previousNode();
253 newFocus = walker.nextNode();
255 if (!newFocus || newFocus.tagName == "toolbartabstop") {
256 // There are no more controls or we hit a tab stop placeholder.
259 this._focusButton(newFocus);
263 let focus = document.activeElement;
266 aEvent.key.length == 1 &&
267 this._isButton(focus) &&
268 // Don't handle characters if the user is focused in a panel anchored
270 !focus.closest("panel")
272 this._onSearchChar(aEvent.currentTarget, aEvent.key);
275 // Anything that doesn't trigger search should clear the search.
283 !this._isButton(focus)
288 switch (aEvent.key) {
290 // Previous if UI is LTR, next if UI is RTL.
291 this.navigateButtons(aEvent.currentTarget, !window.RTL_UI);
294 // Previous if UI is RTL, next if UI is LTR.
295 this.navigateButtons(aEvent.currentTarget, window.RTL_UI);
300 aEvent.preventDefault();
304 this._searchText = "";
305 if (this._clearSearchTimeout) {
306 clearTimeout(this._clearSearchTimeout);
307 this._clearSearchTimeout = null;
311 _onSearchChar(aToolbar, aChar) {
312 if (this._clearSearchTimeout) {
313 // The user just typed a character, so reset the timer.
314 clearTimeout(this._clearSearchTimeout);
316 // Convert to lower case so we can do case insensitive searches.
317 let char = aChar.toLowerCase();
318 // If the user has only typed a single character and they type the same
319 // character again, they want to move to the next item starting with that
320 // same character. Effectively, it's as if there was no existing search.
321 // In that case, we just leave this._searchText alone.
322 if (!this._searchText) {
323 this._searchText = char;
324 } else if (this._searchText != char) {
325 this._searchText += char;
327 // Clear the search if the user doesn't type anything more within the timeout.
328 this._clearSearchTimeout = setTimeout(
329 this._clearSearch.bind(this),
330 this.kSearchClearTimeout
333 let oldFocus = document.activeElement;
334 let walker = this._getWalker(aToolbar);
335 // Search forward after the current control.
336 walker.currentNode = oldFocus;
338 let newFocus = walker.nextNode();
340 newFocus = walker.nextNode()
342 if (this._doesSearchMatch(newFocus)) {
343 this._focusButton(newFocus);
347 // No match, so search from the start until the current control.
348 walker.currentNode = walker.root;
350 let newFocus = walker.firstChild();
351 newFocus && newFocus != oldFocus;
352 newFocus = walker.nextNode()
354 if (this._doesSearchMatch(newFocus)) {
355 this._focusButton(newFocus);
361 _doesSearchMatch(aElem) {
362 if (!this._isButton(aElem)) {
365 for (let attrib of ["aria-label", "label", "tooltiptext"]) {
366 let label = aElem.getAttribute(attrib);
370 // Convert to lower case so we do a case insensitive comparison.
371 // (this._searchText is already lower case.)
372 label = label.toLowerCase();
373 if (label.startsWith(this._searchText)) {
380 _onKeyPress(aEvent) {
381 let focus = document.activeElement;
383 (aEvent.key != "Enter" && aEvent.key != " ") ||
384 !this._isButton(focus)
389 if (focus.getAttribute("type") == "menu") {
394 // Several buttons specifically don't use command events; e.g. because
395 // they want to activate for middle click. Therefore, simulate a click
396 // event if we know they handle click explicitly and don't handle
398 const usesClickInsteadOfCommand = (() => {
399 if (focus.tagName != "toolbarbutton") {
402 return !focus.hasAttribute("oncommand") && focus.hasAttribute("onclick");
405 if (!usesClickInsteadOfCommand) {
408 const ClickEventConstructor = Services.prefs.getBoolPref(
409 "dom.w3c_pointer_events.dispatch_click_as_pointer_event"
414 new ClickEventConstructor("click", {
416 ctrlKey: aEvent.ctrlKey,
417 altKey: aEvent.altKey,
418 shiftKey: aEvent.shiftKey,
419 metaKey: aEvent.metaKey,
424 handleEvent(aEvent) {
425 switch (aEvent.type) {
427 this._onTabStopFocus(aEvent);
430 this._onKeyDown(aEvent);
433 this._onKeyPress(aEvent);
436 this._onButtonBlur(aEvent);