MenuButton.js (13929B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 // A button that toggles a doorhanger menu. 8 9 const flags = require("resource://devtools/shared/flags.js"); 10 const { 11 createRef, 12 PureComponent, 13 } = require("resource://devtools/client/shared/vendor/react.mjs"); 14 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 15 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 16 const { 17 createPortal, 18 } = require("resource://devtools/client/shared/vendor/react-dom.mjs"); 19 const { button } = dom; 20 21 const isMacOS = Services.appinfo.OS === "Darwin"; 22 23 loader.lazyRequireGetter( 24 this, 25 "HTMLTooltip", 26 "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js", 27 true 28 ); 29 30 const lazy = {}; 31 ChromeUtils.defineESModuleGetters(lazy, { 32 focusableSelector: "resource://devtools/client/shared/focus.mjs", 33 }); 34 35 // Return a copy of |obj| minus |fields|. 36 const omit = (obj, fields) => { 37 const objCopy = { ...obj }; 38 for (const field of fields) { 39 delete objCopy[field]; 40 } 41 return objCopy; 42 }; 43 44 class MenuButton extends PureComponent { 45 static get propTypes() { 46 return { 47 // The toolbox document that will be used for rendering the menu popup. 48 toolboxDoc: PropTypes.object.isRequired, 49 50 // A text content for the button. 51 label: PropTypes.string, 52 53 // Optional, either: 54 // - false or missing if no icon should be displayed 55 // - true if an icon should be displayed and is set via CSS 56 // - a string set to the URL of the icon to associate with the MenuButton 57 // e.g. chrome://devtools/skin/image/foo.svg 58 icon: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), 59 60 // An optional ID to assign to the menu's container tooltip object. 61 menuId: PropTypes.string, 62 63 // The preferred side of the anchor element to display the menu. 64 // Defaults to "bottom". 65 menuPosition: PropTypes.string.isRequired, 66 67 // The offset of the menu from the anchor element. 68 // Defaults to -5. 69 menuOffset: PropTypes.number.isRequired, 70 71 // The menu content. 72 children: PropTypes.any, 73 74 // Callback function to be invoked when the button is clicked. 75 onClick: PropTypes.func, 76 77 // Callback function to be invoked when the child panel is closed. 78 onCloseButton: PropTypes.func, 79 }; 80 } 81 82 static get defaultProps() { 83 return { 84 menuPosition: "bottom", 85 menuOffset: -5, 86 }; 87 } 88 89 constructor(props) { 90 super(props); 91 92 this.showMenu = this.showMenu.bind(this); 93 this.hideMenu = this.hideMenu.bind(this); 94 this.toggleMenu = this.toggleMenu.bind(this); 95 this.onHidden = this.onHidden.bind(this); 96 this.onClick = this.onClick.bind(this); 97 this.onKeyDown = this.onKeyDown.bind(this); 98 this.onTouchStart = this.onTouchStart.bind(this); 99 100 this.buttonRef = createRef(); 101 102 this.state = { 103 expanded: false, 104 // In tests, initialize the menu immediately. 105 isMenuInitialized: flags.testing || false, 106 win: props.toolboxDoc.defaultView.top, 107 }; 108 this.ignoreNextClick = false; 109 110 this.initializeTooltip(); 111 } 112 113 componentDidMount() { 114 if (!this.state.isMenuInitialized) { 115 // Initialize the menu when the button is focused or moused over. 116 for (const event of ["focus", "mousemove"]) { 117 this.buttonRef.current.addEventListener( 118 event, 119 () => { 120 if (!this.state.isMenuInitialized) { 121 this.setState({ isMenuInitialized: true }); 122 } 123 }, 124 { once: true } 125 ); 126 } 127 } 128 } 129 130 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 131 UNSAFE_componentWillReceiveProps(nextProps) { 132 // If the window changes, we need to regenerate the HTMLTooltip or else the 133 // XUL wrapper element will appear above (in terms of z-index) the old 134 // window, and not the new. 135 const win = nextProps.toolboxDoc.defaultView.top; 136 if ( 137 nextProps.toolboxDoc !== this.props.toolboxDoc || 138 this.state.win !== win || 139 nextProps.menuId !== this.props.menuId 140 ) { 141 this.setState({ win }); 142 this.resetTooltip(); 143 this.initializeTooltip(); 144 } 145 } 146 147 componentDidUpdate() { 148 // The MenuButton creates the child panel when initializing the MenuButton. 149 // If the children function is called during the rendering process, 150 // this child list size might change. So we need to adjust content size here. 151 if (typeof this.props.children === "function") { 152 this.resizeContent(); 153 } 154 } 155 156 componentWillUnmount() { 157 this.resetTooltip(); 158 } 159 160 initializeTooltip() { 161 const tooltipProps = { 162 type: "doorhanger", 163 useXulWrapper: true, 164 isMenuTooltip: true, 165 }; 166 167 if (this.props.menuId) { 168 tooltipProps.id = this.props.menuId; 169 } 170 171 this.tooltip = new HTMLTooltip(this.props.toolboxDoc, tooltipProps); 172 this.tooltip.on("hidden", this.onHidden); 173 } 174 175 async resetTooltip() { 176 if (!this.tooltip) { 177 return; 178 } 179 180 // Mark the menu as closed since the onHidden callback may not be called in 181 // this case. 182 this.setState({ expanded: false }); 183 this.tooltip.off("hidden", this.onHidden); 184 this.tooltip.destroy(); 185 this.tooltip = null; 186 } 187 188 async showMenu(anchor) { 189 this.setState({ 190 expanded: true, 191 }); 192 193 if (!this.tooltip) { 194 return; 195 } 196 197 await this.tooltip.show(anchor, { 198 position: this.props.menuPosition, 199 y: this.props.menuOffset, 200 }); 201 } 202 203 async hideMenu() { 204 this.setState({ 205 expanded: false, 206 }); 207 208 if (!this.tooltip) { 209 return; 210 } 211 212 await this.tooltip.hide(); 213 } 214 215 async toggleMenu(anchor) { 216 return this.state.expanded ? this.hideMenu() : this.showMenu(anchor); 217 } 218 219 // Used by the call site to indicate that the menu content has changed so 220 // its container should be updated. 221 resizeContent() { 222 if (!this.state.expanded || !this.tooltip || !this.buttonRef.current) { 223 return; 224 } 225 226 this.tooltip.show(this.buttonRef.current, { 227 position: this.props.menuPosition, 228 y: this.props.menuOffset, 229 }); 230 } 231 232 // When we are closing the menu we will get a 'hidden' event before we get 233 // a 'click' event. We want to re-enable the pointer-events: auto setting we 234 // use on the button while the menu is visible, but we don't want to do it 235 // until after the subsequent click event since otherwise we will end up 236 // re-opening the menu. 237 // 238 // For mouse events, we achieve this by using setTimeout(..., 0) to schedule 239 // a separate task to run after the click event, but in the case of touch 240 // events the event order differs and the setTimeout callback will run before 241 // the click event. 242 // 243 // In order to prevent that we detect touch events and set a flag to ignore 244 // the next click event. However, we need to differentiate between touch drag 245 // events and long press events (which don't generate a 'click') and "taps" 246 // (which do). We do that by looking for a 'touchmove' event and clearing the 247 // flag if we get one. 248 onTouchStart(evt) { 249 const touchend = () => { 250 const anchorRect = this.buttonRef.current.getClientRects()[0]; 251 const { clientX, clientY } = evt.changedTouches[0]; 252 // We need to check that the click is inside the bounds since when the 253 // menu is being closed the button will currently have 254 // pointer-events: none (and if we don't check the bounds we will end up 255 // ignoring unrelated clicks). 256 if ( 257 anchorRect.x <= clientX && 258 clientX <= anchorRect.x + anchorRect.width && 259 anchorRect.y <= clientY && 260 clientY <= anchorRect.y + anchorRect.height 261 ) { 262 this.ignoreNextClick = true; 263 } 264 }; 265 266 const touchmove = () => { 267 this.state.win.removeEventListener("touchend", touchend); 268 }; 269 270 this.state.win.addEventListener("touchend", touchend, { once: true }); 271 this.state.win.addEventListener("touchmove", touchmove, { once: true }); 272 } 273 274 onHidden() { 275 this.setState({ expanded: false }); 276 // While the menu is open, if we click _anywhere_ outside the menu, it will 277 // automatically close. This is performed by the XUL wrapper before we get 278 // any chance to see any event. To avoid immediately re-opening the menu 279 // when we process the subsequent click event on this button, we set 280 // 'pointer-events: none' on the button while the menu is open. 281 // 282 // After the menu is closed we need to remove the pointer-events style (so 283 // the button works again) but we don't want to do it immediately since the 284 // "popuphidden" event which triggers this callback might be dispatched 285 // before the "click" event that we want to ignore. As a result, we queue 286 // up a task using setTimeout() to run after the "click" event. 287 this.state.win.setTimeout(() => { 288 if (this.buttonRef.current) { 289 this.buttonRef.current.style.pointerEvents = "auto"; 290 } 291 this.state.win.removeEventListener("touchstart", this.onTouchStart, true); 292 }, 0); 293 294 this.state.win.addEventListener("touchstart", this.onTouchStart, true); 295 296 if (this.props.onCloseButton) { 297 this.props.onCloseButton(); 298 } 299 } 300 301 async onClick(e) { 302 if (this.ignoreNextClick) { 303 this.ignoreNextClick = false; 304 return; 305 } 306 307 if (e.target === this.buttonRef.current) { 308 // On Mac, even after clicking the button it doesn't get focus. 309 // Force focus to the button so that our keydown handlers get called. 310 this.buttonRef.current.focus(); 311 312 if (this.props.onClick) { 313 this.props.onClick(e); 314 } 315 316 if (!e.defaultPrevented) { 317 const wasKeyboardEvent = e.screenX === 0 && e.screenY === 0; 318 // If the popup menu will be shown, disable this button in order to 319 // prevent reopening the popup menu. See extended comment in onHidden(). 320 // above. 321 // 322 // Also, we should _not_ set 'pointer-events: none' if 323 // ui.popup.disable_autohide pref is in effect since, in that case, 324 // there's no redundant hiding behavior and we actually want clicking 325 // the button to close the menu. 326 if ( 327 !this.state.expanded && 328 !Services.prefs.getBoolPref("ui.popup.disable_autohide", false) 329 ) { 330 this.buttonRef.current.style.pointerEvents = "none"; 331 } 332 await this.toggleMenu(e.target); 333 // If the menu was activated by keyboard, focus the first item. 334 if (wasKeyboardEvent && this.tooltip) { 335 this.tooltip.focus(); 336 } 337 338 // MenuButton creates the children dynamically when clicking the button, 339 // so execute the goggle menu after updating the children panel. 340 if (typeof this.props.children === "function") { 341 this.forceUpdate(); 342 } 343 } 344 // If we clicked one of the menu items, then, by default, we should 345 // auto-collapse the menu. 346 // 347 // We check for the defaultPrevented state, however, so that menu items can 348 // turn this behavior off (e.g. a menu item with an embedded button). 349 } else if ( 350 this.state.expanded && 351 !e.defaultPrevented && 352 e.target.matches(lazy.focusableSelector) 353 ) { 354 this.hideMenu(); 355 } 356 } 357 358 onKeyDown(e) { 359 if (!this.state.expanded) { 360 return; 361 } 362 363 const isButtonFocussed = 364 this.props.toolboxDoc && 365 this.props.toolboxDoc.activeElement === this.buttonRef.current; 366 367 switch (e.key) { 368 case "Escape": 369 this.hideMenu(); 370 e.preventDefault(); 371 break; 372 373 case "Tab": 374 case "ArrowDown": 375 if (isButtonFocussed && this.tooltip) { 376 if (this.tooltip.focus()) { 377 e.preventDefault(); 378 } 379 } 380 break; 381 382 case "ArrowUp": 383 if (isButtonFocussed && this.tooltip) { 384 if (this.tooltip.focusEnd()) { 385 e.preventDefault(); 386 } 387 } 388 break; 389 case "t": 390 if ((isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey)) { 391 // Close the menu if the user opens a new tab while it is still open. 392 // 393 // Bug 1499271: Once toolbox has been converted to XUL we should watch 394 // for the 'visibilitychange' event instead of explicitly looking for 395 // Ctrl+T. 396 this.hideMenu(); 397 } 398 break; 399 } 400 } 401 402 render() { 403 const buttonProps = { 404 // Pass through any props set on the button, except the ones we handle 405 // here. 406 ...omit(this.props, Object.keys(MenuButton.propTypes)), 407 onClick: this.onClick, 408 "aria-expanded": this.state.expanded, 409 "aria-haspopup": "menu", 410 ref: this.buttonRef, 411 }; 412 413 if (this.state.expanded) { 414 buttonProps.onKeyDown = this.onKeyDown; 415 } 416 417 if (this.props.menuId) { 418 buttonProps["aria-controls"] = this.props.menuId; 419 } 420 421 if (this.props.icon) { 422 const iconClass = "menu-button--iconic"; 423 buttonProps.className = buttonProps.className 424 ? `${buttonProps.className} ${iconClass}` 425 : iconClass; 426 // `icon` may be a boolean and the icon URL will be set in CSS. 427 if (typeof this.props.icon == "string") { 428 buttonProps.style = { 429 "--menuitem-icon-image": "url(" + this.props.icon + ")", 430 }; 431 } 432 } 433 434 if (this.state.isMenuInitialized) { 435 const menu = createPortal( 436 typeof this.props.children === "function" 437 ? this.props.children() 438 : this.props.children, 439 this.tooltip.panel 440 ); 441 442 return button(buttonProps, this.props.label, menu); 443 } 444 445 return button(buttonProps, this.props.label); 446 } 447 } 448 449 module.exports = MenuButton;