Bug 1461522 - Add Menu components; r=jdescottes
authorBrian Birtles <birtles@gmail.com>
Thu, 28 Jun 2018 15:13:06 +0900
changeset 424996 31e35c02f84b34e06a3afc46d4af2c901c53cdfa
parent 424995 0dead7ce127ba75859381b48ab80d2484ae4451c
child 424997 6670a1258320d262dc26d77501ba11acdfd747e3
push id34231
push userrgurzau@mozilla.com
push dateWed, 04 Jul 2018 16:28:29 +0000
treeherdermozilla-central@a07cf0515fe3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjdescottes
bugs1461522
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1461522 - Add Menu components; r=jdescottes MozReview-Commit-ID: DJVU4rRYQYU
devtools/client/shared/components/menu/MenuButton.js
devtools/client/shared/components/menu/MenuItem.js
devtools/client/shared/components/menu/MenuList.js
devtools/client/shared/components/menu/moz.build
devtools/client/shared/components/moz.build
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/menu/MenuButton.js
@@ -0,0 +1,259 @@
+/* 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/. */
+
+/* eslint-env browser */
+"use strict";
+
+// A button that toggles a doorhanger menu.
+
+const { PureComponent } = require("devtools/client/shared/vendor/react");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const { button } = dom;
+const {
+  HTMLTooltip,
+} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+
+class MenuButton extends PureComponent {
+  static get propTypes() {
+    return {
+      // The document to be used for rendering the menu popup.
+      doc: PropTypes.object.isRequired,
+
+      // An optional ID to assign to the menu's container tooltip object.
+      menuId: PropTypes.string,
+
+      // The preferred side of the anchor element to display the menu.
+      // Defaults to "bottom".
+      menuPosition: PropTypes.string.isRequired,
+
+      // The offset of the menu from the anchor element.
+      // Defaults to -5.
+      menuOffset: PropTypes.number.isRequired,
+
+      // The menu content.
+      children: PropTypes.any,
+
+      // Callback function to be invoked when the button is clicked.
+      onClick: PropTypes.func,
+    };
+  }
+
+  static get defaultProps() {
+    return {
+      menuPosition: "bottom",
+      menuOffset: -5,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.showMenu = this.showMenu.bind(this);
+    this.hideMenu = this.hideMenu.bind(this);
+    this.toggleMenu = this.toggleMenu.bind(this);
+    this.onHidden = this.onHidden.bind(this);
+    this.onClick = this.onClick.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+
+    this.tooltip = null;
+    this.buttonRef = null;
+    this.setButtonRef = element => {
+      this.buttonRef = element;
+    };
+
+    this.state = {
+      expanded: false,
+      win: props.doc.defaultView.top,
+    };
+  }
+
+  componentWillMount() {
+    this.initializeTooltip();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    // If the window changes, we need to regenerate the HTMLTooltip or else the
+    // XUL wrapper element will appear above (in terms of z-index) the old
+    // window, and not the new.
+    const win = nextProps.doc.defaultView.top;
+    if (
+      nextProps.doc !== this.props.doc ||
+      this.state.win !== win ||
+      nextProps.menuId !== this.props.menuId
+    ) {
+      this.setState({ win });
+      this.resetTooltip();
+      this.initializeTooltip();
+    }
+  }
+
+  componentWillUnmount() {
+    this.resetTooltip();
+  }
+
+  initializeTooltip() {
+    const tooltipProps = {
+      type: "doorhanger",
+      useXulWrapper: true,
+    };
+
+    if (this.props.menuId) {
+      tooltipProps.id = this.props.menuId;
+    }
+
+    this.tooltip = new HTMLTooltip(this.props.doc, tooltipProps);
+    this.tooltip.on("hidden", this.onHidden);
+  }
+
+  async resetTooltip() {
+    if (!this.tooltip) {
+      return;
+    }
+
+    // Mark the menu as closed since the onHidden callback may not be called in
+    // this case.
+    this.setState({ expanded: false });
+    this.tooltip.destroy();
+    this.tooltip.off("hidden", this.onHidden);
+    this.tooltip = null;
+  }
+
+  async showMenu(anchor) {
+    this.setState({
+      expanded: true
+    });
+
+    if (!this.tooltip) {
+      return;
+    }
+
+    await this.tooltip.show(anchor, {
+      position: this.props.menuPosition,
+      y: this.props.menuOffset,
+    });
+  }
+
+  async hideMenu() {
+    this.setState({
+      expanded: false
+    });
+
+    if (!this.tooltip) {
+      return;
+    }
+
+    await this.tooltip.hide();
+  }
+
+  async toggleMenu(anchor) {
+    return this.state.expanded ? this.hideMenu() : this.showMenu(anchor);
+  }
+
+  // Used by the call site to indicate that the menu content has changed so
+  // its container should be updated.
+  resizeContent() {
+    if (!this.state.expanded || !this.tooltip || !this.buttonRef) {
+      return;
+    }
+
+    this.tooltip.updateContainerBounds(this.buttonRef, {
+      position: this.props.menuPosition,
+      y: this.props.menuOffset,
+    });
+  }
+
+  onHidden() {
+    this.setState({ expanded: false });
+  }
+
+  async onClick(e) {
+    if (e.target === this.buttonRef) {
+      if (this.props.onClick) {
+        this.props.onClick(e);
+      }
+
+      if (!e.defaultPrevented) {
+        const wasKeyboardEvent = e.screenX === 0 && e.screenY === 0;
+        await this.toggleMenu(e.target);
+        // If the menu was activated by keyboard, focus the first item.
+        if (wasKeyboardEvent && this.tooltip) {
+          this.tooltip.focus();
+        }
+      }
+    // If we clicked one of the menu items, then, by default, we should
+    // auto-collapse the menu.
+    //
+    // We check for the defaultPrevented state, however, so that menu items can
+    // turn this behavior off (e.g. a menu item with an embedded button).
+    } else if (this.state.expanded && !e.defaultPrevented) {
+      this.hideMenu();
+    }
+  }
+
+  onKeyDown(e) {
+    if (!this.state.expanded) {
+      return;
+    }
+
+    const isButtonFocussed =
+      this.props.doc && this.props.doc.activeElement === this.buttonRef;
+
+    switch (e.key) {
+      case "Escape":
+        this.hideMenu();
+        e.preventDefault();
+        break;
+
+      case "Tab":
+      case "ArrowDown":
+        if (isButtonFocussed && this.tooltip) {
+          if (this.tooltip.focus()) {
+            e.preventDefault();
+          }
+        }
+        break;
+
+      case "ArrowUp":
+        if (isButtonFocussed && this.tooltip) {
+          if (this.tooltip.focusEnd()) {
+            e.preventDefault();
+          }
+        }
+        break;
+    }
+  }
+
+  render() {
+    // We bypass the call to HTMLTooltip. setContent and set the panel contents
+    // directly here.
+    //
+    // Bug 1472942: Do this for all users of HTMLTooltip.
+    const menu = ReactDOM.createPortal(
+      this.props.children,
+      this.tooltip.panel
+    );
+
+    const buttonProps = {
+      ...this.props,
+      onClick: this.onClick,
+      "aria-expanded": this.state.expanded,
+      "aria-haspopup": "menu",
+      ref: this.setButtonRef,
+    };
+
+    if (this.state.expanded) {
+      buttonProps.onKeyDown = this.onKeyDown;
+    }
+
+    if (this.props.menuId) {
+      buttonProps["aria-controls"] = this.props.menuId;
+    }
+
+    return button(buttonProps, menu);
+  }
+}
+
+module.exports = MenuButton;
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/menu/MenuItem.js
@@ -0,0 +1,77 @@
+/* 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/. */
+
+/* eslint-env browser */
+"use strict";
+
+// A command in a menu.
+
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const { button, li, span } = dom;
+
+const MenuItem = props => {
+  const attr = {
+    className: "command"
+  };
+
+  if (props.id) {
+    attr.id = props.id;
+  }
+
+  if (props.className) {
+    attr.className += " " + props.className;
+  }
+
+  if (props.onClick) {
+    attr.onClick = props.onClick;
+  }
+
+  if (typeof props.checked !== "undefined") {
+    attr.role = "menuitemcheckbox";
+    if (props.checked) {
+      attr["aria-checked"] = true;
+    }
+  } else {
+    attr.role = "menuitem";
+  }
+
+  const textLabel = span({ className: "label" }, props.label);
+  const children = [textLabel];
+
+  if (typeof props.accelerator !== "undefined") {
+    const acceleratorLabel = span(
+      { className: "accelerator" },
+      props.accelerator
+    );
+    children.push(acceleratorLabel);
+  }
+
+  return li({ className: "menuitem" }, button(attr, children));
+};
+
+MenuItem.propTypes = {
+  // An optional keyboard shortcut to display next to the item.
+  // (This does not actually register the event listener for the key.)
+  accelerator: PropTypes.string,
+
+  // A tri-state value that may be true/false if item should be checkable, and
+  // undefined otherwise.
+  checked: PropTypes.bool,
+
+  // Any additional classes to assign to the button specified as
+  // a space-separated string.
+  className: PropTypes.string,
+
+  // An optional ID to be assigned to the item.
+  id: PropTypes.string,
+
+  // The item label.
+  label: PropTypes.string.isRequired,
+
+  // An optional callback to be invoked when the item is selected.
+  onClick: PropTypes.func,
+};
+
+module.exports = MenuItem;
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/menu/MenuList.js
@@ -0,0 +1,116 @@
+/* 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/. */
+
+/* eslint-env browser */
+"use strict";
+
+// A list of menu items.
+//
+// This component provides keyboard navigation amongst any focusable
+// children.
+
+const { PureComponent } = require("devtools/client/shared/vendor/react");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const { div } = dom;
+const { focusableSelector } = require("devtools/client/shared/focus");
+
+class MenuList extends PureComponent {
+  static get propTypes() {
+    return {
+      // ID to assign to the list container.
+      id: PropTypes.string,
+
+      // Children of the list.
+      children: PropTypes.any,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.onKeyDown = this.onKeyDown.bind(this);
+
+    this.setWrapperRef = element => {
+      this.wrapperRef = element;
+    };
+  }
+
+  onKeyDown(e) {
+    // Check if the focus is in the list.
+    if (
+      !this.wrapperRef ||
+      !this.wrapperRef.contains(e.target.ownerDocument.activeElement)
+    ) {
+      return;
+    }
+
+    const getTabList = () => Array.from(
+      this.wrapperRef.querySelectorAll(focusableSelector)
+    );
+
+    switch (e.key) {
+      case "ArrowUp":
+      case "ArrowDown":
+        {
+          const tabList = getTabList();
+          const currentElement = e.target.ownerDocument.activeElement;
+          const currentIndex = tabList.indexOf(currentElement);
+          if (currentIndex !== -1) {
+            let nextIndex;
+            if (e.key === "ArrowDown") {
+              nextIndex =
+                currentIndex === tabList.length - 1
+                ? 0
+                : currentIndex + 1;
+            } else {
+              nextIndex =
+                currentIndex === 0
+                ? tabList.length - 1
+                : currentIndex - 1;
+            }
+            tabList[nextIndex].focus();
+            e.preventDefault();
+          }
+        }
+        break;
+
+      case "Home":
+        {
+          const firstItem = this.wrapperRef.querySelector(focusableSelector);
+          if (firstItem) {
+            firstItem.focus();
+            e.preventDefault();
+          }
+        }
+        break;
+
+      case "End":
+        {
+          const tabList = getTabList();
+          if (tabList.length) {
+            tabList[tabList.length - 1].focus();
+            e.preventDefault();
+          }
+        }
+        break;
+    }
+  }
+
+  render() {
+    const attr = {
+      role: "menu",
+      ref: this.setWrapperRef,
+      onKeyDown: this.onKeyDown,
+    };
+
+    if (this.props.id) {
+      attr.id = this.props.id;
+    }
+
+    return div(attr, this.props.children);
+  }
+}
+
+module.exports = MenuList;
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/menu/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+  'MenuButton.js',
+  'MenuItem.js',
+  'MenuList.js',
+)
--- a/devtools/client/shared/components/moz.build
+++ b/devtools/client/shared/components/moz.build
@@ -1,15 +1,16 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 DIRS += [
+    'menu',
     'reps',
     'splitter',
     'tabs',
     'throttling',
     'tree',
 ]
 
 DevToolsModules(