summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/components/menu
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/components/menu')
-rw-r--r--devtools/client/shared/components/menu/MenuButton.js450
-rw-r--r--devtools/client/shared/components/menu/MenuItem.js211
-rw-r--r--devtools/client/shared/components/menu/MenuList.js164
-rw-r--r--devtools/client/shared/components/menu/moz.build12
-rw-r--r--devtools/client/shared/components/menu/utils.js62
5 files changed, 899 insertions, 0 deletions
diff --git a/devtools/client/shared/components/menu/MenuButton.js b/devtools/client/shared/components/menu/MenuButton.js
new file mode 100644
index 0000000000..3367987c3c
--- /dev/null
+++ b/devtools/client/shared/components/menu/MenuButton.js
@@ -0,0 +1,450 @@
+/* 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 flags = require("resource://devtools/shared/flags.js");
+const {
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { button } = dom;
+
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+loader.lazyRequireGetter(
+ this,
+ "HTMLTooltip",
+ "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "focusableSelector",
+ "resource://devtools/client/shared/focus.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "createPortal",
+ "resource://devtools/client/shared/vendor/react-dom.js",
+ true
+);
+
+// Return a copy of |obj| minus |fields|.
+const omit = (obj, fields) => {
+ const objCopy = { ...obj };
+ for (const field of fields) {
+ delete objCopy[field];
+ }
+ return objCopy;
+};
+
+class MenuButton extends PureComponent {
+ static get propTypes() {
+ return {
+ // The toolbox document that will be used for rendering the menu popup.
+ toolboxDoc: PropTypes.object.isRequired,
+
+ // A text content for the button.
+ label: PropTypes.string,
+
+ // URL of the icon to associate with the MenuButton. (Optional)
+ // e.g. chrome://devtools/skin/image/foo.svg
+ icon: PropTypes.string,
+
+ // 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,
+
+ // Callback function to be invoked when the child panel is closed.
+ onCloseButton: 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.onTouchStart = this.onTouchStart.bind(this);
+
+ this.buttonRef = createRef();
+
+ this.state = {
+ expanded: false,
+ // In tests, initialize the menu immediately.
+ isMenuInitialized: flags.testing || false,
+ win: props.toolboxDoc.defaultView.top,
+ };
+ this.ignoreNextClick = false;
+
+ this.initializeTooltip();
+ }
+
+ componentDidMount() {
+ if (!this.state.isMenuInitialized) {
+ // Initialize the menu when the button is focused or moused over.
+ for (const event of ["focus", "mousemove"]) {
+ this.buttonRef.current.addEventListener(
+ event,
+ () => {
+ if (!this.state.isMenuInitialized) {
+ this.setState({ isMenuInitialized: true });
+ }
+ },
+ { once: true }
+ );
+ }
+ }
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_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.toolboxDoc.defaultView.top;
+ if (
+ nextProps.toolboxDoc !== this.props.toolboxDoc ||
+ this.state.win !== win ||
+ nextProps.menuId !== this.props.menuId
+ ) {
+ this.setState({ win });
+ this.resetTooltip();
+ this.initializeTooltip();
+ }
+ }
+
+ componentDidUpdate() {
+ // The MenuButton creates the child panel when initializing the MenuButton.
+ // If the children function is called during the rendering process,
+ // this child list size might change. So we need to adjust content size here.
+ if (typeof this.props.children === "function") {
+ this.resizeContent();
+ }
+ }
+
+ componentWillUnmount() {
+ this.resetTooltip();
+ }
+
+ initializeTooltip() {
+ const tooltipProps = {
+ type: "doorhanger",
+ useXulWrapper: true,
+ isMenuTooltip: true,
+ };
+
+ if (this.props.menuId) {
+ tooltipProps.id = this.props.menuId;
+ }
+
+ this.tooltip = new HTMLTooltip(this.props.toolboxDoc, 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.off("hidden", this.onHidden);
+ this.tooltip.destroy();
+ 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.current) {
+ return;
+ }
+
+ this.tooltip.show(this.buttonRef.current, {
+ position: this.props.menuPosition,
+ y: this.props.menuOffset,
+ });
+ }
+
+ // When we are closing the menu we will get a 'hidden' event before we get
+ // a 'click' event. We want to re-enable the pointer-events: auto setting we
+ // use on the button while the menu is visible, but we don't want to do it
+ // until after the subsequent click event since otherwise we will end up
+ // re-opening the menu.
+ //
+ // For mouse events, we achieve this by using setTimeout(..., 0) to schedule
+ // a separate task to run after the click event, but in the case of touch
+ // events the event order differs and the setTimeout callback will run before
+ // the click event.
+ //
+ // In order to prevent that we detect touch events and set a flag to ignore
+ // the next click event. However, we need to differentiate between touch drag
+ // events and long press events (which don't generate a 'click') and "taps"
+ // (which do). We do that by looking for a 'touchmove' event and clearing the
+ // flag if we get one.
+ onTouchStart(evt) {
+ const touchend = () => {
+ const anchorRect = this.buttonRef.current.getClientRects()[0];
+ const { clientX, clientY } = evt.changedTouches[0];
+ // We need to check that the click is inside the bounds since when the
+ // menu is being closed the button will currently have
+ // pointer-events: none (and if we don't check the bounds we will end up
+ // ignoring unrelated clicks).
+ if (
+ anchorRect.x <= clientX &&
+ clientX <= anchorRect.x + anchorRect.width &&
+ anchorRect.y <= clientY &&
+ clientY <= anchorRect.y + anchorRect.height
+ ) {
+ this.ignoreNextClick = true;
+ }
+ };
+
+ const touchmove = () => {
+ this.state.win.removeEventListener("touchend", touchend);
+ };
+
+ this.state.win.addEventListener("touchend", touchend, { once: true });
+ this.state.win.addEventListener("touchmove", touchmove, { once: true });
+ }
+
+ onHidden() {
+ this.setState({ expanded: false });
+ // While the menu is open, if we click _anywhere_ outside the menu, it will
+ // automatically close. This is performed by the XUL wrapper before we get
+ // any chance to see any event. To avoid immediately re-opening the menu
+ // when we process the subsequent click event on this button, we set
+ // 'pointer-events: none' on the button while the menu is open.
+ //
+ // After the menu is closed we need to remove the pointer-events style (so
+ // the button works again) but we don't want to do it immediately since the
+ // "popuphidden" event which triggers this callback might be dispatched
+ // before the "click" event that we want to ignore. As a result, we queue
+ // up a task using setTimeout() to run after the "click" event.
+ this.state.win.setTimeout(() => {
+ if (this.buttonRef.current) {
+ this.buttonRef.current.style.pointerEvents = "auto";
+ }
+ this.state.win.removeEventListener("touchstart", this.onTouchStart, true);
+ }, 0);
+
+ this.state.win.addEventListener("touchstart", this.onTouchStart, true);
+
+ if (this.props.onCloseButton) {
+ this.props.onCloseButton();
+ }
+ }
+
+ async onClick(e) {
+ if (this.ignoreNextClick) {
+ this.ignoreNextClick = false;
+ return;
+ }
+
+ if (e.target === this.buttonRef.current) {
+ // On Mac, even after clicking the button it doesn't get focus.
+ // Force focus to the button so that our keydown handlers get called.
+ this.buttonRef.current.focus();
+
+ if (this.props.onClick) {
+ this.props.onClick(e);
+ }
+
+ if (!e.defaultPrevented) {
+ const wasKeyboardEvent = e.screenX === 0 && e.screenY === 0;
+ // If the popup menu will be shown, disable this button in order to
+ // prevent reopening the popup menu. See extended comment in onHidden().
+ // above.
+ //
+ // Also, we should _not_ set 'pointer-events: none' if
+ // ui.popup.disable_autohide pref is in effect since, in that case,
+ // there's no redundant hiding behavior and we actually want clicking
+ // the button to close the menu.
+ if (
+ !this.state.expanded &&
+ !Services.prefs.getBoolPref("ui.popup.disable_autohide", false)
+ ) {
+ this.buttonRef.current.style.pointerEvents = "none";
+ }
+ await this.toggleMenu(e.target);
+ // If the menu was activated by keyboard, focus the first item.
+ if (wasKeyboardEvent && this.tooltip) {
+ this.tooltip.focus();
+ }
+
+ // MenuButton creates the children dynamically when clicking the button,
+ // so execute the goggle menu after updating the children panel.
+ if (typeof this.props.children === "function") {
+ this.forceUpdate();
+ }
+ }
+ // 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 &&
+ e.target.matches(focusableSelector)
+ ) {
+ this.hideMenu();
+ }
+ }
+
+ onKeyDown(e) {
+ if (!this.state.expanded) {
+ return;
+ }
+
+ const isButtonFocussed =
+ this.props.toolboxDoc &&
+ this.props.toolboxDoc.activeElement === this.buttonRef.current;
+
+ 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;
+ case "t":
+ if ((isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey)) {
+ // Close the menu if the user opens a new tab while it is still open.
+ //
+ // Bug 1499271: Once toolbox has been converted to XUL we should watch
+ // for the 'visibilitychange' event instead of explicitly looking for
+ // Ctrl+T.
+ this.hideMenu();
+ }
+ break;
+ }
+ }
+
+ render() {
+ const buttonProps = {
+ // Pass through any props set on the button, except the ones we handle
+ // here.
+ ...omit(this.props, Object.keys(MenuButton.propTypes)),
+ onClick: this.onClick,
+ "aria-expanded": this.state.expanded,
+ "aria-haspopup": "menu",
+ ref: this.buttonRef,
+ };
+
+ if (this.state.expanded) {
+ buttonProps.onKeyDown = this.onKeyDown;
+ }
+
+ if (this.props.menuId) {
+ buttonProps["aria-controls"] = this.props.menuId;
+ }
+
+ if (this.props.icon) {
+ const iconClass = "menu-button--iconic";
+ buttonProps.className = buttonProps.className
+ ? `${buttonProps.className} ${iconClass}`
+ : iconClass;
+ buttonProps.style = {
+ "--menuitem-icon-image": "url(" + this.props.icon + ")",
+ };
+ }
+
+ if (this.state.isMenuInitialized) {
+ const menu = createPortal(
+ typeof this.props.children === "function"
+ ? this.props.children()
+ : this.props.children,
+ this.tooltip.panel
+ );
+
+ return button(buttonProps, this.props.label, menu);
+ }
+
+ return button(buttonProps, this.props.label);
+ }
+}
+
+module.exports = MenuButton;
diff --git a/devtools/client/shared/components/menu/MenuItem.js b/devtools/client/shared/components/menu/MenuItem.js
new file mode 100644
index 0000000000..c3efa6db6c
--- /dev/null
+++ b/devtools/client/shared/components/menu/MenuItem.js
@@ -0,0 +1,211 @@
+/* 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 {
+ createFactory,
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { button, li, span } = dom;
+loader.lazyGetter(this, "Localized", () =>
+ createFactory(
+ require("resource://devtools/client/shared/vendor/fluent-react.js")
+ .Localized
+ )
+);
+
+class MenuItem extends PureComponent {
+ static get propTypes() {
+ return {
+ // 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,
+
+ // A disabled state of the menu item.
+ disabled: PropTypes.bool,
+
+ // URL of the icon to associate with the MenuItem. (Optional)
+ //
+ // e.g. chrome://devtools/skim/image/foo.svg
+ //
+ // This may also be set in CSS using the --menuitem-icon-image variable.
+ // Note that in this case, the variable should specify the CSS <image> to
+ // use, not simply the URL (e.g.
+ // "url(chrome://devtools/skim/image/foo.svg)").
+ icon: PropTypes.string,
+
+ // An optional ID to be assigned to the item.
+ id: PropTypes.string,
+
+ // The item label for use with legacy localization systems.
+ label: PropTypes.string,
+
+ // The Fluent ID for localizing the label.
+ l10nID: PropTypes.string,
+
+ // An optional callback to be invoked when the item is selected.
+ onClick: PropTypes.func,
+
+ // Optional menu item role override. Use this property with a value
+ // "menuitemradio" if the menu item is a radio.
+ role: PropTypes.string,
+
+ // An optional text for the item tooltip.
+ tooltip: PropTypes.string,
+ };
+ }
+
+ /**
+ * Use this as a fallback `icon` prop if your MenuList contains MenuItems
+ * with or without icon in order to keep all MenuItems aligned.
+ */
+ static get DUMMY_ICON() {
+ return `data:image/svg+xml,${encodeURIComponent(
+ '<svg height="16" width="16"></svg>'
+ )}`;
+ }
+
+ constructor(props) {
+ super(props);
+ this.labelRef = createRef();
+ }
+
+ componentDidMount() {
+ if (!this.labelRef.current) {
+ return;
+ }
+
+ // Pre-fetch any backgrounds specified for the item.
+ const win = this.labelRef.current.ownerDocument.defaultView;
+ this.preloadCallback = win.requestIdleCallback(() => {
+ this.preloadCallback = null;
+ if (!this.labelRef.current) {
+ return;
+ }
+
+ const backgrounds = win
+ .getComputedStyle(this.labelRef.current, ":before")
+ .getCSSImageURLs("background-image");
+ for (const background of backgrounds) {
+ const image = new Image();
+ image.src = background;
+ }
+ });
+ }
+
+ componentWillUnmount() {
+ if (!this.labelRef.current || !this.preloadCallback) {
+ return;
+ }
+
+ const win = this.labelRef.current.ownerDocument.defaultView;
+ if (win) {
+ win.cancelIdleCallback(this.preloadCallback);
+ }
+ this.preloadCallback = null;
+ }
+
+ render() {
+ const attr = {
+ className: "command",
+ };
+
+ if (this.props.id) {
+ attr.id = this.props.id;
+ }
+
+ if (this.props.className) {
+ attr.className += " " + this.props.className;
+ }
+
+ if (this.props.icon) {
+ attr.className += " iconic";
+ attr.style = { "--menuitem-icon-image": "url(" + this.props.icon + ")" };
+ }
+
+ if (this.props.onClick) {
+ attr.onClick = this.props.onClick;
+ }
+
+ if (this.props.tooltip) {
+ attr.title = this.props.tooltip;
+ }
+
+ if (this.props.disabled) {
+ attr.disabled = this.props.disabled;
+ }
+
+ if (this.props.role) {
+ attr.role = this.props.role;
+ } else if (typeof this.props.checked !== "undefined") {
+ attr.role = "menuitemcheckbox";
+ } else {
+ attr.role = "menuitem";
+ }
+
+ if (this.props.checked) {
+ attr["aria-checked"] = true;
+ }
+
+ const children = [];
+ const className = "label";
+
+ // Add the text label.
+ if (this.props.l10nID) {
+ // Fluent localized label.
+ children.push(
+ Localized(
+ { id: this.props.l10nID, key: "label" },
+ span({ className, ref: this.labelRef })
+ )
+ );
+ } else {
+ children.push(
+ span({ key: "label", className, ref: this.labelRef }, this.props.label)
+ );
+ }
+
+ if (this.props.l10nID && this.props.label) {
+ console.warn(
+ "<MenuItem> should only take either an l10nID or a label, not both"
+ );
+ }
+ if (!this.props.l10nID && !this.props.label) {
+ console.warn("<MenuItem> requires either an l10nID, or a label prop.");
+ }
+
+ if (typeof this.props.accelerator !== "undefined") {
+ const acceleratorLabel = span(
+ { key: "accelerator", className: "accelerator" },
+ this.props.accelerator
+ );
+ children.push(acceleratorLabel);
+ }
+
+ return li(
+ {
+ className: "menuitem",
+ role: "presentation",
+ },
+ button(attr, children)
+ );
+ }
+}
+
+module.exports = MenuItem;
diff --git a/devtools/client/shared/components/menu/MenuList.js b/devtools/client/shared/components/menu/MenuList.js
new file mode 100644
index 0000000000..4c355cca10
--- /dev/null
+++ b/devtools/client/shared/components/menu/MenuList.js
@@ -0,0 +1,164 @@
+/* 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 {
+ Children,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { div } = dom;
+const {
+ focusableSelector,
+} = require("resource://devtools/client/shared/focus.js");
+
+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,
+
+ // Called whenever there is a change to the hovered or selected child.
+ // The callback is passed the ID of the highlighted child or null if no
+ // child is highlighted.
+ onHighlightedChildChange: PropTypes.func,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onKeyDown = this.onKeyDown.bind(this);
+ this.onMouseOverOrFocus = this.onMouseOverOrFocus.bind(this);
+ this.onMouseOutOrBlur = this.onMouseOutOrBlur.bind(this);
+ this.notifyHighlightedChildChange =
+ this.notifyHighlightedChildChange.bind(this);
+
+ this.setWrapperRef = element => {
+ this.wrapperRef = element;
+ };
+ }
+
+ onMouseOverOrFocus(e) {
+ this.notifyHighlightedChildChange(e.target.id);
+ }
+
+ onMouseOutOrBlur(e) {
+ const hoveredElem = this.wrapperRef.querySelector(":hover");
+ if (!hoveredElem) {
+ this.notifyHighlightedChildChange(null);
+ }
+ }
+
+ notifyHighlightedChildChange(id) {
+ if (this.props.onHighlightedChildChange) {
+ this.props.onHighlightedChildChange(id);
+ }
+ }
+
+ 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 "Tab":
+ 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" || (e.key === "Tab" && !e.shiftKey)) {
+ 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,
+ onMouseOver: this.onMouseOverOrFocus,
+ onMouseOut: this.onMouseOutOrBlur,
+ onFocus: this.onMouseOverOrFocus,
+ onBlur: this.onMouseOutOrBlur,
+ className: "menu-standard-padding",
+ };
+
+ if (this.props.id) {
+ attr.id = this.props.id;
+ }
+
+ // Add padding for checkbox image if necessary.
+ let hasCheckbox = false;
+ Children.forEach(this.props.children, (child, i) => {
+ if (child == null || typeof child == "undefined") {
+ console.warn("MenuList children at index", i, "is", child);
+ return;
+ }
+
+ if (typeof child?.props?.checked !== "undefined") {
+ hasCheckbox = true;
+ }
+ });
+ if (hasCheckbox) {
+ attr.className = "checkbox-container menu-standard-padding";
+ }
+
+ return div(attr, this.props.children);
+ }
+}
+
+module.exports = MenuList;
diff --git a/devtools/client/shared/components/menu/moz.build b/devtools/client/shared/components/menu/moz.build
new file mode 100644
index 0000000000..08046199e5
--- /dev/null
+++ b/devtools/client/shared/components/menu/moz.build
@@ -0,0 +1,12 @@
+# -*- 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",
+ "utils.js",
+)
diff --git a/devtools/client/shared/components/menu/utils.js b/devtools/client/shared/components/menu/utils.js
new file mode 100644
index 0000000000..e6fca96822
--- /dev/null
+++ b/devtools/client/shared/components/menu/utils.js
@@ -0,0 +1,62 @@
+/* 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/. */
+
+"use strict";
+
+const Menu = require("resource://devtools/client/framework/menu.js");
+const MenuItem = require("resource://devtools/client/framework/menu-item.js");
+
+/**
+ * Helper function for opening context menu.
+ *
+ * @param {Array} items
+ * List of menu items.
+ * @param {Object} options:
+ * @property {Element} button
+ * Button element used to open the menu.
+ * @property {Number} screenX
+ * Screen x coordinate of the menu on the screen.
+ * @property {Number} screenY
+ * Screen y coordinate of the menu on the screen.
+ */
+function showMenu(items, options) {
+ if (items.length === 0) {
+ return;
+ }
+
+ // Build the menu object from provided menu items.
+ const menu = new Menu();
+ items.forEach(item => {
+ if (item == "-") {
+ item = { type: "separator" };
+ }
+
+ const menuItem = new MenuItem(item);
+ const subItems = item.submenu;
+
+ if (subItems) {
+ const subMenu = new Menu();
+ subItems.forEach(subItem => {
+ subMenu.append(new MenuItem(subItem));
+ });
+ menuItem.submenu = subMenu;
+ }
+
+ menu.append(menuItem);
+ });
+
+ // Calculate position on the screen according to
+ // the parent button if available.
+ if (options.button) {
+ menu.popupAtTarget(options.button);
+ } else {
+ const screenX = options.screenX;
+ const screenY = options.screenY;
+ menu.popup(screenX, screenY, window.document);
+ }
+}
+
+module.exports = {
+ showMenu,
+};