Source code
Revision control
Copy as Markdown
Other Tools
/* 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,
/* 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,
// Optional, either:
// - false or missing if no icon should be displayed
// - true if an icon should be displayed and is set via CSS
// - a string set to the URL of the icon to associate with the MenuButton
icon: PropTypes.oneOfType([PropTypes.bool, 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 }
);
}
}
}
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.
//
// 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;
// `icon` may be a boolean and the icon URL will be set in CSS.
if (typeof this.props.icon == "string") {
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;