diff options
Diffstat (limited to 'toolkit/content/widgets/menupopup.js')
-rw-r--r-- | toolkit/content/widgets/menupopup.js | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/toolkit/content/widgets/menupopup.js b/toolkit/content/widgets/menupopup.js new file mode 100644 index 0000000000..359f809a40 --- /dev/null +++ b/toolkit/content/widgets/menupopup.js @@ -0,0 +1,288 @@ +/* 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"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + // For the non-native context menu styling, we need to know if we need + // a gutter for checkboxes. To do this, check whether there are any + // radio/checkbox type menuitems in a menupopup when showing it. We use a + // system bubbling event listener to ensure we run *after* the "normal" + // popupshowing listeners, so (visibility) changes they make to their items + // take effect first, before we check for checkable menuitems. + Services.els.addSystemEventListener( + document, + "popupshowing", + function (e) { + if (e.target.nodeName == "menupopup") { + let haveCheckableChild = e.target.querySelector( + ":scope > menuitem:not([hidden]):is([type=checkbox],[type=radio])" + ); + e.target.toggleAttribute("needsgutter", haveCheckableChild); + } + }, + false + ); + + class MozMenuPopup extends MozElements.MozElementMixin(XULPopupElement) { + constructor() { + super(); + + this.AUTOSCROLL_INTERVAL = 25; + this.NOT_DRAGGING = 0; + this.DRAG_OVER_BUTTON = -1; + this.DRAG_OVER_POPUP = 1; + this._draggingState = this.NOT_DRAGGING; + this._scrollTimer = 0; + + this.attachShadow({ mode: "open" }); + + this.addEventListener("popupshowing", event => { + if (event.target != this) { + return; + } + + // Make sure we generated shadow DOM to place menuitems into. + this.ensureInitialized(); + }); + + this.addEventListener("DOMMenuItemActive", this); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + + this.hasConnected = true; + if (this.parentNode?.localName == "menulist") { + this._setUpMenulistPopup(); + } + } + + initShadowDOM() { + // Retarget events from shadow DOM arrowscrollbox to the host. + this.scrollBox.addEventListener("scroll", ev => + this.dispatchEvent(new Event("scroll")) + ); + this.scrollBox.addEventListener("overflow", ev => + this.dispatchEvent(new Event("overflow")) + ); + this.scrollBox.addEventListener("underflow", ev => + this.dispatchEvent(new Event("underflow")) + ); + } + + ensureInitialized() { + this.shadowRoot; + } + + get shadowRoot() { + if (!super.shadowRoot.firstChild) { + // We generate shadow DOM lazily on popupshowing event to avoid extra + // load on the system during browser startup. + super.shadowRoot.appendChild(this.fragment); + this.initShadowDOM(); + } + return super.shadowRoot; + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment( + this.markup + ); + } + return document.importNode(this.constructor._fragment, true); + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css"/> + <html:style>${this.styles}</html:style> + <arrowscrollbox class="menupopup-arrowscrollbox" + part="arrowscrollbox content" + exportparts="scrollbox: arrowscrollbox-scrollbox" + flex="1" + orient="vertical" + smoothscroll="false"> + <html:slot></html:slot> + </arrowscrollbox> + `; + } + + get styles() { + return ` + :host(.in-menulist) arrowscrollbox::part(scrollbutton-up), + :host(.in-menulist) arrowscrollbox::part(scrollbutton-down) { + display: none; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox) { + overflow: auto; + margin: 0; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox-clip) { + overflow: visible; + } + `; + } + + get scrollBox() { + if (!this._scrollBox) { + this._scrollBox = this.shadowRoot.querySelector("arrowscrollbox"); + } + return this._scrollBox; + } + + /** + * Adds event listeners for a MozMenuPopup inside a menulist element. + */ + _setUpMenulistPopup() { + // Access shadow root to generate menupoup shadow DOMs. We do generate + // shadow DOM on popupshowing, but it doesn't work for HTML:selects, + // which are implemented via menulist elements living in the main process. + // So make them a special case then. + this.ensureInitialized(); + this.classList.add("in-menulist"); + + this.addEventListener("popupshown", () => { + // Enable drag scrolling even when the mouse wasn't used. The + // mousemove handler will remove it if the mouse isn't down. + this._enableDragScrolling(false); + }); + + this.addEventListener("popuphidden", () => { + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + this.releaseCapture(); + this.scrollBox.scrollbox.scrollTop = 0; + }); + + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + if ( + this.state == "open" && + (event.target.localName == "menuitem" || + event.target.localName == "menu" || + event.target.localName == "menucaption") + ) { + this._enableDragScrolling(true); + } + }); + + this.addEventListener("mouseup", event => { + if (event.button != 0) { + return; + } + + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + }); + + this.addEventListener("mousemove", event => { + if (!this._draggingState) { + return; + } + + this._clearScrollTimer(); + + // If the user released the mouse before the menupopup opens, we will + // still be capturing, so check that the button is still pressed. If + // not, release the capture and do nothing else. This also handles if + // the dropdown was opened via the keyboard. + if (!(event.buttons & 1)) { + this._draggingState = this.NOT_DRAGGING; + this.releaseCapture(); + return; + } + + // If dragging outside the top or bottom edge of the menupopup, but + // within the menupopup area horizontally, scroll the list in that + // direction. The _draggingState flag is used to ensure that scrolling + // does not start until the mouse has moved over the menupopup first, + // preventing scrolling while over the dropdown button. + let popupRect = this.getOuterScreenRect(); + if ( + event.screenX >= popupRect.left && + event.screenX <= popupRect.right + ) { + if (this._draggingState == this.DRAG_OVER_BUTTON) { + if ( + event.screenY > popupRect.top && + event.screenY < popupRect.bottom + ) { + this._draggingState = this.DRAG_OVER_POPUP; + } + } + + if ( + this._draggingState == this.DRAG_OVER_POPUP && + (event.screenY <= popupRect.top || + event.screenY >= popupRect.bottom) + ) { + let scrollAmount = event.screenY <= popupRect.top ? -1 : 1; + this.scrollBox.scrollByIndex(scrollAmount, true); + + let win = this.ownerGlobal; + this._scrollTimer = win.setInterval(() => { + this.scrollBox.scrollByIndex(scrollAmount, true); + }, this.AUTOSCROLL_INTERVAL); + } + } + }); + + this._menulistPopupIsSetUp = true; + } + + _enableDragScrolling(overItem) { + if (!this._draggingState) { + this.setCaptureAlways(); + this._draggingState = overItem + ? this.DRAG_OVER_POPUP + : this.DRAG_OVER_BUTTON; + } + } + + _clearScrollTimer() { + if (this._scrollTimer) { + this.ownerGlobal.clearInterval(this._scrollTimer); + this._scrollTimer = 0; + } + } + + on_DOMMenuItemActive(event) { + // Scroll buttons may overlap the active item. In that case, scroll + // further to stay clear of the buttons. + if ( + this.parentNode?.localName == "menulist" || + !this.scrollBox.hasAttribute("overflowing") + ) { + return; + } + let item = event.target; + if (item.parentNode != this) { + return; + } + let itemRect = item.getBoundingClientRect(); + let buttonRect = this.scrollBox._scrollButtonUp.getBoundingClientRect(); + if (buttonRect.bottom > itemRect.top) { + this.scrollBox.scrollByPixels(itemRect.top - buttonRect.bottom, true); + } else { + buttonRect = this.scrollBox._scrollButtonDown.getBoundingClientRect(); + if (buttonRect.top < itemRect.bottom) { + this.scrollBox.scrollByPixels(itemRect.bottom - buttonRect.top, true); + } + } + } + } + + customElements.define("menupopup", MozMenuPopup); + + MozElements.MozMenuPopup = MozMenuPopup; +} |