forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			432 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			432 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* 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 <toolbartabstop/> 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) {
 | 
						|
    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, aPosition) {
 | 
						|
    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;
 | 
						|
    }
 | 
						|
  },
 | 
						|
};
 |