Bug 1461522 - Add Menu components; r?jdescottes draft
authorBrian Birtles <birtles@gmail.com>
Thu, 28 Jun 2018 15:13:06 +0900
changeset 813904 0f4176c8ad2339903ac4c1f766ae447449e8e6b7
parent 813903 10cc3bee686a361edbb3f655ac6216631631f6d5
child 813905 da24e12f5324de58d5617343356391fb501517e0
push id115042
push userbbirtles@mozilla.com
push dateWed, 04 Jul 2018 04:36:27 +0000
reviewersjdescottes
bugs1461522
milestone63.0a1
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(