tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 };