tor-browser

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

MenuButton.js (13929B)


      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 file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 // A button that toggles a doorhanger menu.
      8 
      9 const flags = require("resource://devtools/shared/flags.js");
     10 const {
     11  createRef,
     12  PureComponent,
     13 } = require("resource://devtools/client/shared/vendor/react.mjs");
     14 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
     15 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
     16 const {
     17  createPortal,
     18 } = require("resource://devtools/client/shared/vendor/react-dom.mjs");
     19 const { button } = dom;
     20 
     21 const isMacOS = Services.appinfo.OS === "Darwin";
     22 
     23 loader.lazyRequireGetter(
     24  this,
     25  "HTMLTooltip",
     26  "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js",
     27  true
     28 );
     29 
     30 const lazy = {};
     31 ChromeUtils.defineESModuleGetters(lazy, {
     32  focusableSelector: "resource://devtools/client/shared/focus.mjs",
     33 });
     34 
     35 // Return a copy of |obj| minus |fields|.
     36 const omit = (obj, fields) => {
     37  const objCopy = { ...obj };
     38  for (const field of fields) {
     39    delete objCopy[field];
     40  }
     41  return objCopy;
     42 };
     43 
     44 class MenuButton extends PureComponent {
     45  static get propTypes() {
     46    return {
     47      // The toolbox document that will be used for rendering the menu popup.
     48      toolboxDoc: PropTypes.object.isRequired,
     49 
     50      // A text content for the button.
     51      label: PropTypes.string,
     52 
     53      // Optional, either:
     54      // - false or missing if no icon should be displayed
     55      // - true if an icon should be displayed and is set via CSS
     56      // - a string set to the URL of the icon to associate with the MenuButton
     57      //   e.g. chrome://devtools/skin/image/foo.svg
     58      icon: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
     59 
     60      // An optional ID to assign to the menu's container tooltip object.
     61      menuId: PropTypes.string,
     62 
     63      // The preferred side of the anchor element to display the menu.
     64      // Defaults to "bottom".
     65      menuPosition: PropTypes.string.isRequired,
     66 
     67      // The offset of the menu from the anchor element.
     68      // Defaults to -5.
     69      menuOffset: PropTypes.number.isRequired,
     70 
     71      // The menu content.
     72      children: PropTypes.any,
     73 
     74      // Callback function to be invoked when the button is clicked.
     75      onClick: PropTypes.func,
     76 
     77      // Callback function to be invoked when the child panel is closed.
     78      onCloseButton: PropTypes.func,
     79    };
     80  }
     81 
     82  static get defaultProps() {
     83    return {
     84      menuPosition: "bottom",
     85      menuOffset: -5,
     86    };
     87  }
     88 
     89  constructor(props) {
     90    super(props);
     91 
     92    this.showMenu = this.showMenu.bind(this);
     93    this.hideMenu = this.hideMenu.bind(this);
     94    this.toggleMenu = this.toggleMenu.bind(this);
     95    this.onHidden = this.onHidden.bind(this);
     96    this.onClick = this.onClick.bind(this);
     97    this.onKeyDown = this.onKeyDown.bind(this);
     98    this.onTouchStart = this.onTouchStart.bind(this);
     99 
    100    this.buttonRef = createRef();
    101 
    102    this.state = {
    103      expanded: false,
    104      // In tests, initialize the menu immediately.
    105      isMenuInitialized: flags.testing || false,
    106      win: props.toolboxDoc.defaultView.top,
    107    };
    108    this.ignoreNextClick = false;
    109 
    110    this.initializeTooltip();
    111  }
    112 
    113  componentDidMount() {
    114    if (!this.state.isMenuInitialized) {
    115      // Initialize the menu when the button is focused or moused over.
    116      for (const event of ["focus", "mousemove"]) {
    117        this.buttonRef.current.addEventListener(
    118          event,
    119          () => {
    120            if (!this.state.isMenuInitialized) {
    121              this.setState({ isMenuInitialized: true });
    122            }
    123          },
    124          { once: true }
    125        );
    126      }
    127    }
    128  }
    129 
    130  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
    131  UNSAFE_componentWillReceiveProps(nextProps) {
    132    // If the window changes, we need to regenerate the HTMLTooltip or else the
    133    // XUL wrapper element will appear above (in terms of z-index) the old
    134    // window, and not the new.
    135    const win = nextProps.toolboxDoc.defaultView.top;
    136    if (
    137      nextProps.toolboxDoc !== this.props.toolboxDoc ||
    138      this.state.win !== win ||
    139      nextProps.menuId !== this.props.menuId
    140    ) {
    141      this.setState({ win });
    142      this.resetTooltip();
    143      this.initializeTooltip();
    144    }
    145  }
    146 
    147  componentDidUpdate() {
    148    // The MenuButton creates the child panel when initializing the MenuButton.
    149    // If the children function is called during the rendering process,
    150    // this child list size might change. So we need to adjust content size here.
    151    if (typeof this.props.children === "function") {
    152      this.resizeContent();
    153    }
    154  }
    155 
    156  componentWillUnmount() {
    157    this.resetTooltip();
    158  }
    159 
    160  initializeTooltip() {
    161    const tooltipProps = {
    162      type: "doorhanger",
    163      useXulWrapper: true,
    164      isMenuTooltip: true,
    165    };
    166 
    167    if (this.props.menuId) {
    168      tooltipProps.id = this.props.menuId;
    169    }
    170 
    171    this.tooltip = new HTMLTooltip(this.props.toolboxDoc, tooltipProps);
    172    this.tooltip.on("hidden", this.onHidden);
    173  }
    174 
    175  async resetTooltip() {
    176    if (!this.tooltip) {
    177      return;
    178    }
    179 
    180    // Mark the menu as closed since the onHidden callback may not be called in
    181    // this case.
    182    this.setState({ expanded: false });
    183    this.tooltip.off("hidden", this.onHidden);
    184    this.tooltip.destroy();
    185    this.tooltip = null;
    186  }
    187 
    188  async showMenu(anchor) {
    189    this.setState({
    190      expanded: true,
    191    });
    192 
    193    if (!this.tooltip) {
    194      return;
    195    }
    196 
    197    await this.tooltip.show(anchor, {
    198      position: this.props.menuPosition,
    199      y: this.props.menuOffset,
    200    });
    201  }
    202 
    203  async hideMenu() {
    204    this.setState({
    205      expanded: false,
    206    });
    207 
    208    if (!this.tooltip) {
    209      return;
    210    }
    211 
    212    await this.tooltip.hide();
    213  }
    214 
    215  async toggleMenu(anchor) {
    216    return this.state.expanded ? this.hideMenu() : this.showMenu(anchor);
    217  }
    218 
    219  // Used by the call site to indicate that the menu content has changed so
    220  // its container should be updated.
    221  resizeContent() {
    222    if (!this.state.expanded || !this.tooltip || !this.buttonRef.current) {
    223      return;
    224    }
    225 
    226    this.tooltip.show(this.buttonRef.current, {
    227      position: this.props.menuPosition,
    228      y: this.props.menuOffset,
    229    });
    230  }
    231 
    232  // When we are closing the menu we will get a 'hidden' event before we get
    233  // a 'click' event. We want to re-enable the pointer-events: auto setting we
    234  // use on the button while the menu is visible, but we don't want to do it
    235  // until after the subsequent click event since otherwise we will end up
    236  // re-opening the menu.
    237  //
    238  // For mouse events, we achieve this by using setTimeout(..., 0) to schedule
    239  // a separate task to run after the click event, but in the case of touch
    240  // events the event order differs and the setTimeout callback will run before
    241  // the click event.
    242  //
    243  // In order to prevent that we detect touch events and set a flag to ignore
    244  // the next click event. However, we need to differentiate between touch drag
    245  // events and long press events (which don't generate a 'click') and "taps"
    246  // (which do). We do that by looking for a 'touchmove' event and clearing the
    247  // flag if we get one.
    248  onTouchStart(evt) {
    249    const touchend = () => {
    250      const anchorRect = this.buttonRef.current.getClientRects()[0];
    251      const { clientX, clientY } = evt.changedTouches[0];
    252      // We need to check that the click is inside the bounds since when the
    253      // menu is being closed the button will currently have
    254      // pointer-events: none (and if we don't check the bounds we will end up
    255      // ignoring unrelated clicks).
    256      if (
    257        anchorRect.x <= clientX &&
    258        clientX <= anchorRect.x + anchorRect.width &&
    259        anchorRect.y <= clientY &&
    260        clientY <= anchorRect.y + anchorRect.height
    261      ) {
    262        this.ignoreNextClick = true;
    263      }
    264    };
    265 
    266    const touchmove = () => {
    267      this.state.win.removeEventListener("touchend", touchend);
    268    };
    269 
    270    this.state.win.addEventListener("touchend", touchend, { once: true });
    271    this.state.win.addEventListener("touchmove", touchmove, { once: true });
    272  }
    273 
    274  onHidden() {
    275    this.setState({ expanded: false });
    276    // While the menu is open, if we click _anywhere_ outside the menu, it will
    277    // automatically close. This is performed by the XUL wrapper before we get
    278    // any chance to see any event. To avoid immediately re-opening the menu
    279    // when we process the subsequent click event on this button, we set
    280    // 'pointer-events: none' on the button while the menu is open.
    281    //
    282    // After the menu is closed we need to remove the pointer-events style (so
    283    // the button works again) but we don't want to do it immediately since the
    284    // "popuphidden" event which triggers this callback might be dispatched
    285    // before the "click" event that we want to ignore.  As a result, we queue
    286    // up a task using setTimeout() to run after the "click" event.
    287    this.state.win.setTimeout(() => {
    288      if (this.buttonRef.current) {
    289        this.buttonRef.current.style.pointerEvents = "auto";
    290      }
    291      this.state.win.removeEventListener("touchstart", this.onTouchStart, true);
    292    }, 0);
    293 
    294    this.state.win.addEventListener("touchstart", this.onTouchStart, true);
    295 
    296    if (this.props.onCloseButton) {
    297      this.props.onCloseButton();
    298    }
    299  }
    300 
    301  async onClick(e) {
    302    if (this.ignoreNextClick) {
    303      this.ignoreNextClick = false;
    304      return;
    305    }
    306 
    307    if (e.target === this.buttonRef.current) {
    308      // On Mac, even after clicking the button it doesn't get focus.
    309      // Force focus to the button so that our keydown handlers get called.
    310      this.buttonRef.current.focus();
    311 
    312      if (this.props.onClick) {
    313        this.props.onClick(e);
    314      }
    315 
    316      if (!e.defaultPrevented) {
    317        const wasKeyboardEvent = e.screenX === 0 && e.screenY === 0;
    318        // If the popup menu will be shown, disable this button in order to
    319        // prevent reopening the popup menu. See extended comment in onHidden().
    320        // above.
    321        //
    322        // Also, we should _not_ set 'pointer-events: none' if
    323        // ui.popup.disable_autohide pref is in effect since, in that case,
    324        // there's no redundant hiding behavior and we actually want clicking
    325        // the button to close the menu.
    326        if (
    327          !this.state.expanded &&
    328          !Services.prefs.getBoolPref("ui.popup.disable_autohide", false)
    329        ) {
    330          this.buttonRef.current.style.pointerEvents = "none";
    331        }
    332        await this.toggleMenu(e.target);
    333        // If the menu was activated by keyboard, focus the first item.
    334        if (wasKeyboardEvent && this.tooltip) {
    335          this.tooltip.focus();
    336        }
    337 
    338        // MenuButton creates the children dynamically when clicking the button,
    339        // so execute the goggle menu after updating the children panel.
    340        if (typeof this.props.children === "function") {
    341          this.forceUpdate();
    342        }
    343      }
    344      // If we clicked one of the menu items, then, by default, we should
    345      // auto-collapse the menu.
    346      //
    347      // We check for the defaultPrevented state, however, so that menu items can
    348      // turn this behavior off (e.g. a menu item with an embedded button).
    349    } else if (
    350      this.state.expanded &&
    351      !e.defaultPrevented &&
    352      e.target.matches(lazy.focusableSelector)
    353    ) {
    354      this.hideMenu();
    355    }
    356  }
    357 
    358  onKeyDown(e) {
    359    if (!this.state.expanded) {
    360      return;
    361    }
    362 
    363    const isButtonFocussed =
    364      this.props.toolboxDoc &&
    365      this.props.toolboxDoc.activeElement === this.buttonRef.current;
    366 
    367    switch (e.key) {
    368      case "Escape":
    369        this.hideMenu();
    370        e.preventDefault();
    371        break;
    372 
    373      case "Tab":
    374      case "ArrowDown":
    375        if (isButtonFocussed && this.tooltip) {
    376          if (this.tooltip.focus()) {
    377            e.preventDefault();
    378          }
    379        }
    380        break;
    381 
    382      case "ArrowUp":
    383        if (isButtonFocussed && this.tooltip) {
    384          if (this.tooltip.focusEnd()) {
    385            e.preventDefault();
    386          }
    387        }
    388        break;
    389      case "t":
    390        if ((isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey)) {
    391          // Close the menu if the user opens a new tab while it is still open.
    392          //
    393          // Bug 1499271: Once toolbox has been converted to XUL we should watch
    394          // for the 'visibilitychange' event instead of explicitly looking for
    395          // Ctrl+T.
    396          this.hideMenu();
    397        }
    398        break;
    399    }
    400  }
    401 
    402  render() {
    403    const buttonProps = {
    404      // Pass through any props set on the button, except the ones we handle
    405      // here.
    406      ...omit(this.props, Object.keys(MenuButton.propTypes)),
    407      onClick: this.onClick,
    408      "aria-expanded": this.state.expanded,
    409      "aria-haspopup": "menu",
    410      ref: this.buttonRef,
    411    };
    412 
    413    if (this.state.expanded) {
    414      buttonProps.onKeyDown = this.onKeyDown;
    415    }
    416 
    417    if (this.props.menuId) {
    418      buttonProps["aria-controls"] = this.props.menuId;
    419    }
    420 
    421    if (this.props.icon) {
    422      const iconClass = "menu-button--iconic";
    423      buttonProps.className = buttonProps.className
    424        ? `${buttonProps.className} ${iconClass}`
    425        : iconClass;
    426      // `icon` may be a boolean and the icon URL will be set in CSS.
    427      if (typeof this.props.icon == "string") {
    428        buttonProps.style = {
    429          "--menuitem-icon-image": "url(" + this.props.icon + ")",
    430        };
    431      }
    432    }
    433 
    434    if (this.state.isMenuInitialized) {
    435      const menu = createPortal(
    436        typeof this.props.children === "function"
    437          ? this.props.children()
    438          : this.props.children,
    439        this.tooltip.panel
    440      );
    441 
    442      return button(buttonProps, this.props.label, menu);
    443    }
    444 
    445    return button(buttonProps, this.props.label);
    446  }
    447 }
    448 
    449 module.exports = MenuButton;