Bug 1461522 - Add Menu components; r?jdescottes
MozReview-Commit-ID: DJVU4rRYQYU
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(