/* 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);
}