browser-toolbarKeyNav.js (13971B)
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/. */ 4 5 /** 6 * Handle keyboard navigation for toolbars. 7 * Having separate tab stops for every toolbar control results in an 8 * unmanageable number of tab stops. Therefore, we group buttons under a single 9 * tab stop and allow movement between them using left/right arrows. 10 * However, text inputs use the arrow keys for their own purposes, so they need 11 * their own tab stop. There are also groups of buttons before and after the 12 * URL bar input which should get their own tab stop. The subsequent buttons on 13 * the toolbar are then another tab stop after that. 14 * Tab stops for groups of buttons are set using the <toolbartabstop/> element. 15 * This element is invisible, but gets included in the tab order. When one of 16 * these gets focus, it redirects focus to the appropriate button. This avoids 17 * the need to continually manage the tabindex of toolbar buttons in response to 18 * toolbarchanges. 19 * In addition to linear navigation with tab and arrows, users can also type 20 * the first (or first few) characters of a button's name to jump directly to 21 * that button. 22 */ 23 24 ToolbarKeyboardNavigator = { 25 // Toolbars we want to be keyboard navigable. 26 kToolbars: [ 27 CustomizableUI.AREA_TABSTRIP, 28 CustomizableUI.AREA_NAVBAR, 29 CustomizableUI.AREA_BOOKMARKS, 30 ], 31 // Delay (in ms) after which to clear any search text typed by the user if 32 // the user hasn't typed anything further. 33 kSearchClearTimeout: 1000, 34 35 _isButton(aElem) { 36 if (aElem.getAttribute("keyNav") === "false") { 37 return false; 38 } 39 return ( 40 aElem.tagName == "toolbarbutton" || aElem.getAttribute("role") == "button" 41 ); 42 }, 43 44 // Get a TreeWalker which includes only controls which should be keyboard 45 // navigable. 46 _getWalker(aRoot) { 47 if (aRoot._toolbarKeyNavWalker) { 48 return aRoot._toolbarKeyNavWalker; 49 } 50 51 let filter = aNode => { 52 if (aNode.tagName == "toolbartabstop") { 53 return NodeFilter.FILTER_ACCEPT; 54 } 55 56 // Special case for the "View site information" button, which isn't 57 // actionable in some cases but is still visible. 58 if ( 59 aNode.id == "identity-box" && 60 document.getElementById("urlbar").getAttribute("pageproxystate") == 61 "invalid" 62 ) { 63 return NodeFilter.FILTER_REJECT; 64 } 65 66 // Skip disabled elements. 67 if (aNode.disabled) { 68 return NodeFilter.FILTER_REJECT; 69 } 70 71 // Skip invisible elements. 72 const visible = aNode.checkVisibility({ 73 checkVisibilityCSS: true, 74 flush: false, 75 }); 76 if (!visible) { 77 return NodeFilter.FILTER_REJECT; 78 } 79 80 // This width check excludes the overflow button when there's no overflow. 81 const bounds = window.windowUtils.getBoundsWithoutFlushing(aNode); 82 if (bounds.width == 0) { 83 return NodeFilter.FILTER_SKIP; 84 } 85 86 if (this._isButton(aNode)) { 87 return NodeFilter.FILTER_ACCEPT; 88 } 89 return NodeFilter.FILTER_SKIP; 90 }; 91 aRoot._toolbarKeyNavWalker = document.createTreeWalker( 92 aRoot, 93 NodeFilter.SHOW_ELEMENT, 94 filter 95 ); 96 return aRoot._toolbarKeyNavWalker; 97 }, 98 99 _initTabStops(aRoot) { 100 for (let stop of aRoot.getElementsByTagName("toolbartabstop")) { 101 // These are invisible, but because they need to be in the tab order, 102 // they can't get display: none or similar. They must therefore be 103 // explicitly hidden for accessibility. 104 stop.setAttribute("aria-hidden", "true"); 105 stop.addEventListener("focus", this); 106 } 107 }, 108 109 init() { 110 for (let id of this.kToolbars) { 111 let toolbar = document.getElementById(id); 112 // When enabled, no toolbar buttons should themselves be tabbable. 113 // We manage toolbar focus completely. This attribute ensures that CSS 114 // doesn't set -moz-user-focus: normal. 115 toolbar.setAttribute("keyNav", "true"); 116 this._initTabStops(toolbar); 117 toolbar.addEventListener("keydown", this); 118 toolbar.addEventListener("keypress", this); 119 } 120 CustomizableUI.addListener(this); 121 }, 122 123 uninit() { 124 for (let id of this.kToolbars) { 125 let toolbar = document.getElementById(id); 126 for (let stop of toolbar.getElementsByTagName("toolbartabstop")) { 127 stop.removeEventListener("focus", this); 128 } 129 toolbar.removeEventListener("keydown", this); 130 toolbar.removeEventListener("keypress", this); 131 toolbar.removeAttribute("keyNav"); 132 } 133 CustomizableUI.removeListener(this); 134 }, 135 136 // CustomizableUI event handler 137 onWidgetAdded(aWidgetId, aArea) { 138 if (!this.kToolbars.includes(aArea)) { 139 return; 140 } 141 let widget = document.getElementById(aWidgetId); 142 if (!widget) { 143 return; 144 } 145 this._initTabStops(widget); 146 }, 147 148 _focusButton(aButton) { 149 // Toolbar buttons aren't focusable because if they were, clicking them 150 // would focus them, which is undesirable. Therefore, we must make a 151 // button focusable only when we want to focus it. 152 aButton.setAttribute("tabindex", "-1"); 153 aButton.focus(); 154 // We could remove tabindex now, but even though the button keeps DOM 155 // focus, a11y gets confused because the button reports as not being 156 // focusable. This results in weirdness if the user switches windows and 157 // then switches back. It also means that focus can't be restored to the 158 // button when a panel is closed. Instead, remove tabindex when the button 159 // loses focus. 160 aButton.addEventListener("blur", this); 161 }, 162 163 _onButtonBlur(aEvent) { 164 if (document.activeElement == aEvent.target) { 165 // This event was fired because the user switched windows. This button 166 // will get focus again when the user returns. 167 return; 168 } 169 if (aEvent.target.getAttribute("open") == "true") { 170 // The button activated a panel. The button should remain 171 // focusable so that focus can be restored when the panel closes. 172 return; 173 } 174 aEvent.target.removeEventListener("blur", this); 175 aEvent.target.removeAttribute("tabindex"); 176 }, 177 178 _onTabStopFocus(aEvent) { 179 let toolbar = aEvent.target.closest("toolbar"); 180 let walker = this._getWalker(toolbar); 181 182 let oldFocus = aEvent.relatedTarget; 183 if (oldFocus) { 184 // Save this because we might rewind focus and the subsequent focus event 185 // won't get a relatedTarget. 186 this._isFocusMovingBackward = 187 oldFocus.compareDocumentPosition(aEvent.target) & 188 Node.DOCUMENT_POSITION_PRECEDING; 189 if (this._isFocusMovingBackward && oldFocus && this._isButton(oldFocus)) { 190 // Shift+tabbing from a button will land on its toolbartabstop. Skip it. 191 document.commandDispatcher.rewindFocus(); 192 return; 193 } 194 } 195 196 walker.currentNode = aEvent.target; 197 let button = walker.nextNode(); 198 if (!button || !this._isButton(button)) { 199 // If we think we're moving backward, and focus came from outside the 200 // toolbox, we might actually have wrapped around. In this case, the 201 // event target was the first tabstop. If we can't find a button, e.g. 202 // because we're in a popup where most buttons are hidden, we 203 // should ensure focus keeps moving forward: 204 if ( 205 this._isFocusMovingBackward && 206 (!oldFocus || !gNavToolbox.contains(oldFocus)) 207 ) { 208 let allStops = Array.from( 209 gNavToolbox.querySelectorAll("toolbartabstop") 210 ); 211 // Find the previous toolbartabstop: 212 let earlierVisibleStopIndex = allStops.indexOf(aEvent.target) - 1; 213 // Then work out if any of the earlier ones are in a visible 214 // toolbar: 215 while (earlierVisibleStopIndex >= 0) { 216 let stopToolbar = 217 allStops[earlierVisibleStopIndex].closest("toolbar"); 218 if (!stopToolbar.collapsed) { 219 break; 220 } 221 earlierVisibleStopIndex--; 222 } 223 // If we couldn't find any earlier visible stops, we're not moving 224 // backwards, we're moving forwards and wrapped around: 225 if (earlierVisibleStopIndex == -1) { 226 this._isFocusMovingBackward = false; 227 } 228 } 229 // No navigable buttons for this tab stop. Skip it. 230 if (this._isFocusMovingBackward) { 231 document.commandDispatcher.rewindFocus(); 232 } else { 233 document.commandDispatcher.advanceFocus(); 234 } 235 return; 236 } 237 238 this._focusButton(button); 239 }, 240 241 navigateButtons(aToolbar, aPrevious) { 242 let oldFocus = document.activeElement; 243 let walker = this._getWalker(aToolbar); 244 // Start from the current control and walk to the next/previous control. 245 walker.currentNode = oldFocus; 246 let newFocus; 247 if (aPrevious) { 248 newFocus = walker.previousNode(); 249 } else { 250 newFocus = walker.nextNode(); 251 } 252 if (!newFocus || newFocus.tagName == "toolbartabstop") { 253 // There are no more controls or we hit a tab stop placeholder. 254 return; 255 } 256 this._focusButton(newFocus); 257 }, 258 259 _onKeyDown(aEvent) { 260 let focus = document.activeElement; 261 if ( 262 aEvent.key != " " && 263 aEvent.key.length == 1 && 264 this._isButton(focus) && 265 // Don't handle characters if the user is focused in a panel anchored 266 // to the toolbar. 267 !focus.closest("panel") 268 ) { 269 this._onSearchChar(aEvent.currentTarget, aEvent.key); 270 return; 271 } 272 // Anything that doesn't trigger search should clear the search. 273 this._clearSearch(); 274 275 if ( 276 aEvent.altKey || 277 aEvent.controlKey || 278 aEvent.metaKey || 279 aEvent.shiftKey || 280 !this._isButton(focus) 281 ) { 282 return; 283 } 284 285 switch (aEvent.key) { 286 case "ArrowLeft": 287 // Previous if UI is LTR, next if UI is RTL. 288 this.navigateButtons(aEvent.currentTarget, !window.RTL_UI); 289 break; 290 case "ArrowRight": 291 // Previous if UI is RTL, next if UI is LTR. 292 this.navigateButtons(aEvent.currentTarget, window.RTL_UI); 293 break; 294 default: 295 return; 296 } 297 aEvent.preventDefault(); 298 }, 299 300 _clearSearch() { 301 this._searchText = ""; 302 if (this._clearSearchTimeout) { 303 clearTimeout(this._clearSearchTimeout); 304 this._clearSearchTimeout = null; 305 } 306 }, 307 308 _onSearchChar(aToolbar, aChar) { 309 if (this._clearSearchTimeout) { 310 // The user just typed a character, so reset the timer. 311 clearTimeout(this._clearSearchTimeout); 312 } 313 // Convert to lower case so we can do case insensitive searches. 314 let char = aChar.toLowerCase(); 315 // If the user has only typed a single character and they type the same 316 // character again, they want to move to the next item starting with that 317 // same character. Effectively, it's as if there was no existing search. 318 // In that case, we just leave this._searchText alone. 319 if (!this._searchText) { 320 this._searchText = char; 321 } else if (this._searchText != char) { 322 this._searchText += char; 323 } 324 // Clear the search if the user doesn't type anything more within the timeout. 325 this._clearSearchTimeout = setTimeout( 326 this._clearSearch.bind(this), 327 this.kSearchClearTimeout 328 ); 329 330 let oldFocus = document.activeElement; 331 let walker = this._getWalker(aToolbar); 332 // Search forward after the current control. 333 walker.currentNode = oldFocus; 334 for ( 335 let newFocus = walker.nextNode(); 336 newFocus; 337 newFocus = walker.nextNode() 338 ) { 339 if (this._doesSearchMatch(newFocus)) { 340 this._focusButton(newFocus); 341 return; 342 } 343 } 344 // No match, so search from the start until the current control. 345 walker.currentNode = walker.root; 346 for ( 347 let newFocus = walker.firstChild(); 348 newFocus && newFocus != oldFocus; 349 newFocus = walker.nextNode() 350 ) { 351 if (this._doesSearchMatch(newFocus)) { 352 this._focusButton(newFocus); 353 return; 354 } 355 } 356 }, 357 358 _doesSearchMatch(aElem) { 359 if (!this._isButton(aElem)) { 360 return false; 361 } 362 for (let attrib of ["aria-label", "label", "tooltiptext"]) { 363 let label = aElem.getAttribute(attrib); 364 if (!label) { 365 continue; 366 } 367 // Convert to lower case so we do a case insensitive comparison. 368 // (this._searchText is already lower case.) 369 label = label.toLowerCase(); 370 if (label.startsWith(this._searchText)) { 371 return true; 372 } 373 } 374 return false; 375 }, 376 377 _onKeyPress(aEvent) { 378 let focus = document.activeElement; 379 if ( 380 (aEvent.key != "Enter" && aEvent.key != " ") || 381 !this._isButton(focus) 382 ) { 383 return; 384 } 385 386 if (focus.getAttribute("type") == "menu") { 387 focus.open = true; 388 return; 389 } 390 391 // Several buttons specifically don't use command events; e.g. because 392 // they want to activate for middle click. Therefore, simulate a click 393 // event if we know they handle click explicitly and don't handle 394 // commands. 395 const usesClickInsteadOfCommand = (() => { 396 if (focus.tagName != "toolbarbutton") { 397 return true; 398 } 399 return !focus.hasAttribute("oncommand") && focus.hasAttribute("onclick"); 400 })(); 401 402 if (!usesClickInsteadOfCommand) { 403 return; 404 } 405 focus.dispatchEvent( 406 new PointerEvent("click", { 407 bubbles: true, 408 ctrlKey: aEvent.ctrlKey, 409 altKey: aEvent.altKey, 410 shiftKey: aEvent.shiftKey, 411 metaKey: aEvent.metaKey, 412 }) 413 ); 414 }, 415 416 handleEvent(aEvent) { 417 switch (aEvent.type) { 418 case "focus": 419 this._onTabStopFocus(aEvent); 420 break; 421 case "keydown": 422 this._onKeyDown(aEvent); 423 break; 424 case "keypress": 425 this._onKeyPress(aEvent); 426 break; 427 case "blur": 428 this._onButtonBlur(aEvent); 429 break; 430 } 431 }, 432 };