Bug 1909074. Don't pass OFFSET_BY_ORIGIN to GetResultingTransformMatrix when it's...
[gecko.git] / browser / base / content / browser-toolbarKeyNav.js
blobe5159a75ffdfc9006c3099b09498f24aa3b2bf7a
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 */
8 /**
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
21  * toolbarchanges.
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
24  * that button.
25  */
27 ToolbarKeyboardNavigator = {
28   // Toolbars we want to be keyboard navigable.
29   kToolbars: [
30     CustomizableUI.AREA_TABSTRIP,
31     CustomizableUI.AREA_NAVBAR,
32     CustomizableUI.AREA_BOOKMARKS,
33   ],
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,
38   _isButton(aElem) {
39     if (aElem.getAttribute("keyNav") === "false") {
40       return false;
41     }
42     return (
43       aElem.tagName == "toolbarbutton" || aElem.getAttribute("role") == "button"
44     );
45   },
47   // Get a TreeWalker which includes only controls which should be keyboard
48   // navigable.
49   _getWalker(aRoot) {
50     if (aRoot._toolbarKeyNavWalker) {
51       return aRoot._toolbarKeyNavWalker;
52     }
54     let filter = aNode => {
55       if (aNode.tagName == "toolbartabstop") {
56         return NodeFilter.FILTER_ACCEPT;
57       }
59       // Special case for the "View site information" button, which isn't
60       // actionable in some cases but is still visible.
61       if (
62         aNode.id == "identity-box" &&
63         document.getElementById("urlbar").getAttribute("pageproxystate") ==
64           "invalid"
65       ) {
66         return NodeFilter.FILTER_REJECT;
67       }
69       // Skip disabled elements.
70       if (aNode.disabled) {
71         return NodeFilter.FILTER_REJECT;
72       }
74       // Skip invisible elements.
75       const visible = aNode.checkVisibility({
76         checkVisibilityCSS: true,
77         flush: false,
78       });
79       if (!visible) {
80         return NodeFilter.FILTER_REJECT;
81       }
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;
87       }
89       if (this._isButton(aNode)) {
90         return NodeFilter.FILTER_ACCEPT;
91       }
92       return NodeFilter.FILTER_SKIP;
93     };
94     aRoot._toolbarKeyNavWalker = document.createTreeWalker(
95       aRoot,
96       NodeFilter.SHOW_ELEMENT,
97       filter
98     );
99     return aRoot._toolbarKeyNavWalker;
100   },
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);
109     }
110   },
112   init() {
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);
122     }
123     CustomizableUI.addListener(this);
124   },
126   uninit() {
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);
131       }
132       toolbar.removeEventListener("keydown", this);
133       toolbar.removeEventListener("keypress", this);
134       toolbar.removeAttribute("keyNav");
135     }
136     CustomizableUI.removeListener(this);
137   },
139   // CustomizableUI event handler
140   onWidgetAdded(aWidgetId, aArea) {
141     if (!this.kToolbars.includes(aArea)) {
142       return;
143     }
144     let widget = document.getElementById(aWidgetId);
145     if (!widget) {
146       return;
147     }
148     this._initTabStops(widget);
149   },
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");
156     aButton.focus();
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
162     // loses focus.
163     aButton.addEventListener("blur", this);
164   },
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.
170       return;
171     }
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.
175       return;
176     }
177     aEvent.target.removeEventListener("blur", this);
178     aEvent.target.removeAttribute("tabindex");
179   },
181   _onTabStopFocus(aEvent) {
182     let toolbar = aEvent.target.closest("toolbar");
183     let walker = this._getWalker(toolbar);
185     let oldFocus = aEvent.relatedTarget;
186     if (oldFocus) {
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();
195         return;
196       }
197     }
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:
207       if (
208         this._isFocusMovingBackward &&
209         (!oldFocus || !gNavToolbox.contains(oldFocus))
210       ) {
211         let allStops = Array.from(
212           gNavToolbox.querySelectorAll("toolbartabstop")
213         );
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
217         // toolbar:
218         while (earlierVisibleStopIndex >= 0) {
219           let stopToolbar =
220             allStops[earlierVisibleStopIndex].closest("toolbar");
221           if (!stopToolbar.collapsed) {
222             break;
223           }
224           earlierVisibleStopIndex--;
225         }
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;
230         }
231       }
232       // No navigable buttons for this tab stop. Skip it.
233       if (this._isFocusMovingBackward) {
234         document.commandDispatcher.rewindFocus();
235       } else {
236         document.commandDispatcher.advanceFocus();
237       }
238       return;
239     }
241     this._focusButton(button);
242   },
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;
249     let newFocus;
250     if (aPrevious) {
251       newFocus = walker.previousNode();
252     } else {
253       newFocus = walker.nextNode();
254     }
255     if (!newFocus || newFocus.tagName == "toolbartabstop") {
256       // There are no more controls or we hit a tab stop placeholder.
257       return;
258     }
259     this._focusButton(newFocus);
260   },
262   _onKeyDown(aEvent) {
263     let focus = document.activeElement;
264     if (
265       aEvent.key != " " &&
266       aEvent.key.length == 1 &&
267       this._isButton(focus) &&
268       // Don't handle characters if the user is focused in a panel anchored
269       // to the toolbar.
270       !focus.closest("panel")
271     ) {
272       this._onSearchChar(aEvent.currentTarget, aEvent.key);
273       return;
274     }
275     // Anything that doesn't trigger search should clear the search.
276     this._clearSearch();
278     if (
279       aEvent.altKey ||
280       aEvent.controlKey ||
281       aEvent.metaKey ||
282       aEvent.shiftKey ||
283       !this._isButton(focus)
284     ) {
285       return;
286     }
288     switch (aEvent.key) {
289       case "ArrowLeft":
290         // Previous if UI is LTR, next if UI is RTL.
291         this.navigateButtons(aEvent.currentTarget, !window.RTL_UI);
292         break;
293       case "ArrowRight":
294         // Previous if UI is RTL, next if UI is LTR.
295         this.navigateButtons(aEvent.currentTarget, window.RTL_UI);
296         break;
297       default:
298         return;
299     }
300     aEvent.preventDefault();
301   },
303   _clearSearch() {
304     this._searchText = "";
305     if (this._clearSearchTimeout) {
306       clearTimeout(this._clearSearchTimeout);
307       this._clearSearchTimeout = null;
308     }
309   },
311   _onSearchChar(aToolbar, aChar) {
312     if (this._clearSearchTimeout) {
313       // The user just typed a character, so reset the timer.
314       clearTimeout(this._clearSearchTimeout);
315     }
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;
326     }
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
331     );
333     let oldFocus = document.activeElement;
334     let walker = this._getWalker(aToolbar);
335     // Search forward after the current control.
336     walker.currentNode = oldFocus;
337     for (
338       let newFocus = walker.nextNode();
339       newFocus;
340       newFocus = walker.nextNode()
341     ) {
342       if (this._doesSearchMatch(newFocus)) {
343         this._focusButton(newFocus);
344         return;
345       }
346     }
347     // No match, so search from the start until the current control.
348     walker.currentNode = walker.root;
349     for (
350       let newFocus = walker.firstChild();
351       newFocus && newFocus != oldFocus;
352       newFocus = walker.nextNode()
353     ) {
354       if (this._doesSearchMatch(newFocus)) {
355         this._focusButton(newFocus);
356         return;
357       }
358     }
359   },
361   _doesSearchMatch(aElem) {
362     if (!this._isButton(aElem)) {
363       return false;
364     }
365     for (let attrib of ["aria-label", "label", "tooltiptext"]) {
366       let label = aElem.getAttribute(attrib);
367       if (!label) {
368         continue;
369       }
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)) {
374         return true;
375       }
376     }
377     return false;
378   },
380   _onKeyPress(aEvent) {
381     let focus = document.activeElement;
382     if (
383       (aEvent.key != "Enter" && aEvent.key != " ") ||
384       !this._isButton(focus)
385     ) {
386       return;
387     }
389     if (focus.getAttribute("type") == "menu") {
390       focus.open = true;
391       return;
392     }
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
397     // commands.
398     const usesClickInsteadOfCommand = (() => {
399       if (focus.tagName != "toolbarbutton") {
400         return true;
401       }
402       return !focus.hasAttribute("oncommand") && focus.hasAttribute("onclick");
403     })();
405     if (!usesClickInsteadOfCommand) {
406       return;
407     }
408     const ClickEventConstructor = Services.prefs.getBoolPref(
409       "dom.w3c_pointer_events.dispatch_click_as_pointer_event"
410     )
411       ? PointerEvent
412       : MouseEvent;
413     focus.dispatchEvent(
414       new ClickEventConstructor("click", {
415         bubbles: true,
416         ctrlKey: aEvent.ctrlKey,
417         altKey: aEvent.altKey,
418         shiftKey: aEvent.shiftKey,
419         metaKey: aEvent.metaKey,
420       })
421     );
422   },
424   handleEvent(aEvent) {
425     switch (aEvent.type) {
426       case "focus":
427         this._onTabStopFocus(aEvent);
428         break;
429       case "keydown":
430         this._onKeyDown(aEvent);
431         break;
432       case "keypress":
433         this._onKeyPress(aEvent);
434         break;
435       case "blur":
436         this._onButtonBlur(aEvent);
437         break;
438     }
439   },