From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- toolkit/content/widgets/panel-list/panel-list.js | 836 +++++++++++++++++++++++ 1 file changed, 836 insertions(+) create mode 100644 toolkit/content/widgets/panel-list/panel-list.js (limited to 'toolkit/content/widgets/panel-list/panel-list.js') diff --git a/toolkit/content/widgets/panel-list/panel-list.js b/toolkit/content/widgets/panel-list/panel-list.js new file mode 100644 index 0000000000..1cc1f865c3 --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-list.js @@ -0,0 +1,836 @@ +/* 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"; + +{ + class PanelList extends HTMLElement { + static get observedAttributes() { + return ["open"]; + } + + static get fragment() { + if (!this._template) { + let parser = new DOMParser(); + let cssPath = "chrome://global/content/elements/panel-list.css"; + let doc = parser.parseFromString( + ` + + `, + "text/html" + ); + this._template = document.importNode( + doc.querySelector("template"), + true + ); + } + return this._template.content.cloneNode(true); + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.constructor.fragment); + } + + connectedCallback() { + this.setAttribute("role", "menu"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "open" && newVal != oldVal) { + if (this.open) { + this.onShow(); + } else { + this.onHide(); + } + } + } + + get open() { + return this.hasAttribute("open"); + } + + set open(val) { + this.toggleAttribute("open", val); + } + + get stayOpen() { + return this.hasAttribute("stay-open"); + } + + set stayOpen(val) { + this.toggleAttribute("stay-open", val); + } + + getTargetForEvent(event) { + if (!event) { + return null; + } + if (event._savedComposedTarget) { + return event._savedComposedTarget; + } + if (event.composed) { + event._savedComposedTarget = + event.composedTarget || event.composedPath()[0]; + } + return event._savedComposedTarget || event.target; + } + + show(triggeringEvent, target) { + this.triggeringEvent = triggeringEvent; + this.lastAnchorNode = + target || this.getTargetForEvent(this.triggeringEvent); + + this.wasOpenedByKeyboard = + triggeringEvent && + (triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD || + triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_UNKNOWN || + triggeringEvent.code == "ArrowRight" || + triggeringEvent.code == "ArrowLeft"); + this.open = true; + + if (this.parentIsXULPanel()) { + this.toggleAttribute("inxulpanel", true); + let panel = this.parentElement; + panel.hidden = false; + // Bug 1842070 - There appears to be a race here where panel-lists + // embedded in XUL panels won't appear during the first call to show() + // without waiting for a mix of rAF and another tick of the event + // loop. + requestAnimationFrame(() => { + setTimeout(() => { + panel.openPopup( + this.lastAnchorNode, + "after_start", + 0, + 0, + false, + false, + this.triggeringEvent + ); + }, 0); + }); + } else { + this.toggleAttribute("inxulpanel", false); + } + } + + hide(triggeringEvent, { force = false } = {}, eventTarget) { + // It's possible this is being used in an unprivileged context, in which + // case it won't have access to Services / Services will be undeclared. + const autohideDisabled = this.hasServices() + ? Services.prefs.getBoolPref("ui.popup.disable_autohide", false) + : false; + + if (autohideDisabled && !force) { + // Don't hide if this wasn't "forced" (using escape or click in menu). + return; + } + let openingEvent = this.triggeringEvent; + this.triggeringEvent = triggeringEvent; + this.open = false; + + if (this.parentIsXULPanel()) { + // It's possible that we're being programattically hidden, in which + // case, we need to hide the XUL panel we're embedded in. If, however, + // we're being hidden because the XUL panel is being hidden, calling + // hidePopup again on it is a no-op. + let panel = this.parentElement; + panel.hidePopup(); + } + + let target = eventTarget || this.getTargetForEvent(openingEvent); + // Refocus the button that opened the menu if we have one. + if (target && this.wasOpenedByKeyboard) { + target.focus(); + } + } + + toggle(triggeringEvent, target = null) { + if (this.open) { + this.hide(triggeringEvent, { force: true }, target); + } else { + this.show(triggeringEvent, target); + } + } + + hasServices() { + // Safely check for Services without throwing a ReferenceError. + return typeof Services !== "undefined"; + } + + isDocumentRTL() { + if (this.hasServices()) { + return Services.locale.isAppLocaleRTL; + } + return document.dir === "rtl"; + } + + parentIsXULPanel() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return ( + this.parentElement?.namespaceURI == XUL_NS && + this.parentElement?.localName == "panel" + ); + } + + async setAlign() { + const hostElement = this.parentElement || this.getRootNode().host; + if (!hostElement) { + // This could get called before we're added to the DOM. + // Nothing to do in that case. + return; + } + + // Set the showing attribute to hide the panel until its alignment is set. + this.setAttribute("showing", "true"); + // Tell the host element to hide any overflow in case the panel extends off + // the page before the alignment is set. + hostElement.style.overflow = "hidden"; + + // Wait for a layout flush, then find the bounds. + let { + anchorBottom, // distance from the bottom of the anchor el to top of viewport. + anchorLeft, + anchorTop, + anchorWidth, + panelHeight, + panelWidth, + winHeight, + winScrollY, + winScrollX, + clientWidth, + } = await new Promise(resolve => { + this.style.left = 0; + this.style.top = 0; + + requestAnimationFrame(() => + setTimeout(() => { + let target = this.getTargetForEvent(this.triggeringEvent); + let anchorElement = target || hostElement; + // It's possible this is being used in a context where windowUtils is + // not available. In that case, fallback to using the element. + let getBounds = el => + window.windowUtils + ? window.windowUtils.getBoundsWithoutFlushing(el) + : el.getBoundingClientRect(); + // Use y since top is reserved. + let anchorBounds = getBounds(anchorElement); + let panelBounds = getBounds(this); + let clientWidth = document.scrollingElement.clientWidth; + + resolve({ + anchorBottom: anchorBounds.bottom, + anchorHeight: anchorBounds.height, + anchorLeft: anchorBounds.left, + anchorTop: anchorBounds.top, + anchorWidth: anchorBounds.width, + panelHeight: panelBounds.height, + panelWidth: panelBounds.width, + winHeight: innerHeight, + winScrollX: scrollX, + winScrollY: scrollY, + clientWidth, + }); + }, 0) + ); + }); + + // If we're embedded in a XUL panel, let it handle alignment. + if (!this.parentIsXULPanel()) { + // Calculate the left/right alignment. + let align; + let leftOffset; + let leftAlignX = anchorLeft; + let rightAlignX = anchorLeft + anchorWidth - panelWidth; + + if (this.isDocumentRTL()) { + // Prefer aligning on the right. + align = rightAlignX < 0 ? "left" : "right"; + } else { + // Prefer aligning on the left. + align = leftAlignX + panelWidth > clientWidth ? "right" : "left"; + } + leftOffset = align === "left" ? leftAlignX : rightAlignX; + + let bottomSpaceY = winHeight - anchorBottom; + + let valign; + let topOffset; + const VIEWPORT_PANEL_MIN_MARGIN = 10; // 10px ensures that the panel is not flush with the viewport. + + // Only want to valign top when there's more space between the bottom of the anchor element and the top of the viewport. + // If there's more space between the bottom of the anchor element and the bottom of the viewport, we valign bottom. + if ( + anchorBottom > bottomSpaceY && + anchorBottom + panelHeight > winHeight + ) { + // Never want to have a negative value for topOffset, so ensure it's at least 10px. + topOffset = Math.max( + anchorTop - panelHeight, + VIEWPORT_PANEL_MIN_MARGIN + ); + // Provide a max-height for larger elements which will provide scrolling as needed. + this.style.maxHeight = `${anchorTop + VIEWPORT_PANEL_MIN_MARGIN}px`; + valign = "top"; + } else { + topOffset = anchorBottom; + this.style.maxHeight = `${ + bottomSpaceY - VIEWPORT_PANEL_MIN_MARGIN + }px`; + valign = "bottom"; + } + + // Set the alignments and show the panel. + this.setAttribute("align", align); + this.setAttribute("valign", valign); + hostElement.style.overflow = ""; + + this.style.left = `${leftOffset + winScrollX}px`; + this.style.top = `${topOffset + winScrollY}px`; + } + + this.style.minWidth = this.hasAttribute("min-width-from-anchor") + ? `${anchorWidth}px` + : ""; + + this.removeAttribute("showing"); + } + + addHideListeners() { + if (this.hasAttribute("stay-open") && !this.lastAnchorNode.hasSubmenu) { + // This is intended for inspection in Storybook. + return; + } + // Hide when a panel-item is clicked in the list. + this.addEventListener("click", this); + // Allows submenus to stopPropagation when focus is already in the menu + this.addEventListener("keydown", this); + // We need Escape/Tab/ArrowDown to work when opened with the mouse. + document.addEventListener("keydown", this); + // Hide when a click is initiated outside the panel. + document.addEventListener("mousedown", this); + // Hide if focus changes and the panel isn't in focus. + document.addEventListener("focusin", this); + // Reset or focus tracking, we treat the first focusin differently. + this.focusHasChanged = false; + // Hide on resize, scroll or losing window focus. + window.addEventListener("resize", this); + window.addEventListener("scroll", this, { capture: true }); + window.addEventListener("blur", this); + if (this.parentIsXULPanel()) { + this.parentElement.addEventListener("popuphidden", this); + } + } + + removeHideListeners() { + this.removeEventListener("click", this); + this.removeEventListener("keydown", this); + document.removeEventListener("keydown", this); + document.removeEventListener("mousedown", this); + document.removeEventListener("focusin", this); + window.removeEventListener("resize", this); + window.removeEventListener("scroll", this, { capture: true }); + window.removeEventListener("blur", this); + if (this.parentIsXULPanel()) { + this.parentElement.removeEventListener("popuphidden", this); + } + } + + handleEvent(e) { + // Ignore the event if it caused the panel to open. + if (e == this.triggeringEvent) { + return; + } + + let target = this.getTargetForEvent(e); + let inPanelList = e.composed + ? e.composedPath().some(el => el == this) + : e.target.closest && e.target.closest("panel-list") == this; + + switch (e.type) { + case "resize": + case "scroll": + if (inPanelList) { + break; + } + // Intentional fall-through + case "blur": + case "popuphidden": + this.hide(); + break; + case "click": + if (inPanelList) { + this.hide(undefined, { force: true }); + } else { + // Avoid falling through to the default click handler of the parent. + e.stopPropagation(); + } + break; + case "mousedown": + // Close if there's a click started outside the panel. + if (!inPanelList) { + this.hide(); + } + break; + case "keydown": + if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab") { + // Ignore tabbing with a modifer other than shift. + if (e.key === "Tab" && (e.altKey || e.ctrlKey || e.metaKey)) { + return; + } + + // Don't scroll the page or let the regular tab order take effect. + e.preventDefault(); + + // Prevents the host panel list from responding to these events while + // the submenu is active. + e.stopPropagation(); + + // Keep moving to the next/previous element sibling until we find a + // panel-item that isn't hidden. + let moveForward = + e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey); + + let nextItem = moveForward + ? this.focusWalker.nextNode() + : this.focusWalker.previousNode(); + + // If the next item wasn't found, try looping to the top/bottom. + if (!nextItem) { + this.focusWalker.currentNode = this; + if (moveForward) { + nextItem = this.focusWalker.firstChild(); + } else { + nextItem = this.focusWalker.lastChild(); + } + } + break; + } else if (e.key === "Escape") { + this.hide(undefined, { force: true }); + } else if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { + // Check if any of the children have an accesskey for this letter. + let item = this.querySelector( + `[accesskey="${e.key.toLowerCase()}"], + [accesskey="${e.key.toUpperCase()}"]` + ); + if (item) { + item.click(); + } + } + break; + case "focusin": + if ( + this.triggeringEvent && + target == this.getTargetForEvent(this.triggeringEvent) && + !this.focusHasChanged + ) { + // There will be a focusin after the mousedown that opens the panel + // using the mouse. Ignore the first focusin event if it's on the + // triggering target. + this.focusHasChanged = true; + } else if (!target || !inPanelList) { + // If the target isn't in the panel, hide. This will close when focus + // moves out of the panel. + this.hide(); + } else { + // Just record that there was a focusin event. + this.focusHasChanged = true; + } + break; + } + } + + /** + * A TreeWalker that can be used to focus elements. The returned element will + * be the element that has gained focus based on the requested movement + * through the tree. + * + * Example: + * + * this.focusWalker.currentNode = this; + * // Focus and get the first focusable child. + * let focused = this.focusWalker.nextNode(); + * // Focus the second focusable child. + * this.focusWalker.nextNode(); + */ + get focusWalker() { + if (!this._focusWalker) { + this._focusWalker = document.createTreeWalker( + this, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => { + // No need to look at hidden nodes. + if (node.hidden) { + return NodeFilter.FILTER_REJECT; + } + + // Focus the node, if it worked then this is the node we want. + node.focus(); + if (node === node.getRootNode().activeElement) { + return NodeFilter.FILTER_ACCEPT; + } + + // Continue into child nodes if the parent couldn't be focused. + return NodeFilter.FILTER_SKIP; + }, + } + ); + } + return this._focusWalker; + } + async setSubmenuAlign() { + const hostElement = + this.lastAnchorNode.parentElement || this.getRootNode().host; + // The showing attribute allows layout of the panel while remaining hidden + // from the user until alignment is set. + this.setAttribute("showing", "true"); + + // Wait for a layout flush, then find the bounds. + let { + anchorLeft, + anchorWidth, + anchorTop, + parentPanelTop, + panelWidth, + clientWidth, + } = await new Promise(resolve => { + requestAnimationFrame(() => { + // It's possible this is being used in a context where windowUtils is + // not available. In that case, fallback to using the element. + let getBounds = el => + window.windowUtils + ? window.windowUtils.getBoundsWithoutFlushing(el) + : el.getBoundingClientRect(); + // submenu item in the parent panel list + let anchorBounds = getBounds(this.lastAnchorNode); + let parentPanelBounds = getBounds(hostElement); + let panelBounds = getBounds(this); + let clientWidth = document.scrollingElement.clientWidth; + + resolve({ + anchorLeft: anchorBounds.left, + anchorWidth: anchorBounds.width, + anchorTop: anchorBounds.top, + parentPanelTop: parentPanelBounds.top, + panelWidth: panelBounds.width, + clientWidth, + }); + }); + }); + + let align = hostElement.getAttribute("align"); + + // we use document.scrollingElement.clientWidth to exclude the width + // of vertical scrollbars, because its inclusion can cause the submenu + // to open to the wrong side and be overlapped by the scrollbar. + if ( + align == "left" && + anchorLeft + anchorWidth + panelWidth < clientWidth + ) { + this.style.left = `${anchorWidth}px`; + this.style.right = ""; + } else { + this.style.right = `${anchorWidth}px`; + this.style.left = ""; + } + + let topOffset = + anchorTop - + parentPanelTop - + (parseFloat(window.getComputedStyle(this)?.paddingTop) || 0); + this.style.top = `${topOffset}px`; + + this.removeAttribute("showing"); + } + + async onShow() { + this.sendEvent("showing"); + this.addHideListeners(); + + if (this.lastAnchorNode?.hasSubmenu) { + await this.setSubmenuAlign(); + } else { + await this.setAlign(); + } + + // Always reset this regardless of how the panel list is opened + // so the first child will be focusable. + this.focusWalker.currentNode = this; + + // Wait until the next paint for the alignment to be set and panel to be + // visible. + requestAnimationFrame(() => { + if (this.wasOpenedByKeyboard) { + // Focus the first focusable panel-item if opened by keyboard. + this.focusWalker.nextNode(); + } + + this.lastAnchorNode?.setAttribute("aria-expanded", "true"); + + this.sendEvent("shown"); + }); + } + + onHide() { + requestAnimationFrame(() => { + this.sendEvent("hidden"); + this.lastAnchorNode?.setAttribute("aria-expanded", "false"); + }); + this.removeHideListeners(); + } + + sendEvent(name, detail) { + this.dispatchEvent( + new CustomEvent(name, { detail, bubbles: true, composed: true }) + ); + } + } + customElements.define("panel-list", PanelList); + + class PanelItem extends HTMLElement { + #initialized = false; + #defaultSlot; + + static get observedAttributes() { + return ["accesskey"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://global/content/elements/panel-item.css"; + + this.button = document.createElement("button"); + this.button.setAttribute("role", "menuitem"); + this.button.setAttribute("part", "button"); + // Use a XUL label element if possible to show the accesskey. + this.label = document.createXULElement + ? document.createXULElement("label") + : document.createElement("span"); + + this.button.appendChild(this.label); + + let supportLinkSlot = document.createElement("slot"); + supportLinkSlot.name = "support-link"; + + this.#defaultSlot = document.createElement("slot"); + this.#defaultSlot.style.display = "none"; + + if (this.hasSubmenu) { + this.icon = document.createElement("div"); + this.icon.setAttribute("class", "submenu-icon"); + this.label.setAttribute("class", "submenu-label"); + + this.button.setAttribute("class", "submenu-container"); + this.button.appendChild(this.icon); + + this.submenuSlot = document.createElement("slot"); + this.submenuSlot.name = "submenu"; + + this.shadowRoot.append( + style, + this.button, + this.#defaultSlot, + this.submenuSlot + ); + } else { + this.shadowRoot.append( + style, + this.button, + supportLinkSlot, + this.#defaultSlot + ); + } + } + + connectedCallback() { + if (!this._l10nRootConnected && document.l10n) { + document.l10n.connectRoot(this.shadowRoot); + this._l10nRootConnected = true; + } + + if (!this.#initialized) { + this.#initialized = true; + // When click listeners are added to the panel-item it creates a node in + // the a11y tree for this element. This breaks the association between the + // menu and the button[role="menuitem"] in this shadow DOM and causes + // announcement issues with screen readers. (bug 995064) + this.setAttribute("role", "presentation"); + + this.#setLabelContents(); + + // When our content changes, move the text into the label. It doesn't work + // with a , unfortunately. + new MutationObserver(() => this.#setLabelContents()).observe(this, { + characterData: true, + childList: true, + subtree: true, + }); + + if (this.hasSubmenu) { + this.setSubmenuContents(); + } + } + + this.panel = + this.getRootNode()?.host?.closest("panel-list") || + this.closest("panel-list"); + + if (this.panel) { + this.panel.addEventListener("hidden", this); + this.panel.addEventListener("shown", this); + } + if (this.hasSubmenu) { + this.addEventListener("mouseenter", this); + this.addEventListener("mouseleave", this); + this.addEventListener("keydown", this); + } + } + + disconnectedCallback() { + if (this._l10nRootConnected) { + document.l10n.disconnectRoot(this.shadowRoot); + this._l10nRootConnected = false; + } + + if (this.panel) { + this.panel.removeEventListener("hidden", this); + this.panel.removeEventListener("shown", this); + this.panel = null; + } + + if (this.hasSubmenu) { + this.removeEventListener("mouseenter", this); + this.removeEventListener("mouseleave", this); + this.removeEventListener("keydown", this); + } + } + + get hasSubmenu() { + return this.hasAttribute("submenu"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name === "accesskey") { + // Bug 1037709 - Accesskey doesn't work in shadow DOM. + // Ideally we'd have the accesskey set in shadow DOM, and on + // attributeChangedCallback we'd just update the shadow DOM accesskey. + + // Skip this change event if we caused it. + if (this._modifyingAccessKey) { + this._modifyingAccessKey = false; + return; + } + + this.label.accessKey = newVal || ""; + + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + if (!this.panel || !this.panel.open) { + // When the panel isn't open, just store the key for later. + this._accessKey = newVal || null; + this._modifyingAccessKey = true; + this.accessKey = ""; + } else { + this._accessKey = null; + } + } + } + + #setLabelContents() { + this.label.textContent = this.#defaultSlot + .assignedNodes() + .map(node => node.textContent) + .join(""); + } + + setSubmenuContents() { + this.submenuPanel = this.submenuSlot.assignedNodes()[0]; + this.shadowRoot.append(this.submenuPanel); + } + + get disabled() { + return this.button.hasAttribute("disabled"); + } + + set disabled(val) { + this.button.toggleAttribute("disabled", val); + } + + get checked() { + return this.hasAttribute("checked"); + } + + set checked(val) { + this.toggleAttribute("checked", val); + } + + focus() { + this.button.focus(); + } + + setArrowKeyRTL() { + let arrowOpenKey = "ArrowRight"; + let arrowCloseKey = "ArrowLeft"; + + if (this.submenuPanel.isDocumentRTL()) { + arrowOpenKey = "ArrowLeft"; + arrowCloseKey = "ArrowRight"; + } + return [arrowOpenKey, arrowCloseKey]; + } + + handleEvent(e) { + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + switch (e.type) { + case "shown": + if (this._accessKey) { + this.accessKey = this._accessKey; + this._accessKey = null; + } + break; + case "hidden": + if (this.accessKey) { + this._accessKey = this.accessKey; + this._modifyingAccessKey = true; + this.accessKey = ""; + } + break; + case "mouseenter": + case "mouseleave": + this.submenuPanel.toggle(e); + break; + case "keydown": + let [arrowOpenKey, arrowCloseKey] = this.setArrowKeyRTL(); + if (e.key === arrowOpenKey) { + this.submenuPanel.show(e, e.target); + e.stopPropagation(); + } + if (e.key === arrowCloseKey) { + this.submenuPanel.hide(e, { force: true }, e.target); + e.stopPropagation(); + } + break; + } + } + } + customElements.define("panel-item", PanelItem); +} -- cgit v1.2.3