diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/shared/components/menu | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/shared/components/menu')
-rw-r--r-- | devtools/client/shared/components/menu/MenuButton.js | 450 | ||||
-rw-r--r-- | devtools/client/shared/components/menu/MenuItem.js | 211 | ||||
-rw-r--r-- | devtools/client/shared/components/menu/MenuList.js | 164 | ||||
-rw-r--r-- | devtools/client/shared/components/menu/moz.build | 12 | ||||
-rw-r--r-- | devtools/client/shared/components/menu/utils.js | 62 |
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, +}; |