/* 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 doc = parser.parseFromString(
`
`,
"text/html"
);
this._template = document.importNode(
doc.querySelector("template"),
true
);
}
let frag = this._template.content.cloneNode(true);
if (window.IS_STORYBOOK) {
frag.querySelector("link").href = "./panel-list.css";
}
return frag;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
// Ensure that the element is hidden even if its main stylesheet hasn't
// loaded yet. On initial load, or with cache disabled, the element could
// briefly flicker before the stylesheet is loaded without this.
let style = document.createElement("style");
style.textContent = `
:host(:not([open])) {
display: none;
}
`;
this.shadowRoot.appendChild(style);
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);
}
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) {
this.triggeringEvent = triggeringEvent;
this.lastAnchorNode = this.getTargetForEvent(this.triggeringEvent);
this.wasOpenedByKeyboard =
triggeringEvent &&
(triggeringEvent.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD ||
triggeringEvent.mozInputSource == MouseEvent.MOZ_SOURCE_UNKNOWN);
this.open = true;
if (this.parentIsXULPanel()) {
this.toggleAttribute("inxulpanel", true);
let panel = this.parentElement;
panel.hidden = false;
panel.openPopup(
this.lastAnchorNode,
"after_start",
0,
0,
false,
false,
this.triggeringEvent
);
} else {
this.toggleAttribute("inxulpanel", false);
}
}
hide(triggeringEvent, { force = false } = {}) {
// 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 = this.getTargetForEvent(openingEvent);
// Refocus the button that opened the menu if we have one.
if (target && this.wasOpenedByKeyboard) {
target.focus();
}
}
toggle(triggeringEvent) {
if (this.open) {
this.hide(triggeringEvent, { force: true });
} else {
this.show(triggeringEvent);
}
}
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 {
anchorHeight,
anchorLeft,
anchorTop,
anchorWidth,
panelHeight,
panelWidth,
winHeight,
winScrollY,
winScrollX,
winWidth,
} = 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);
resolve({
anchorHeight: anchorBounds.height,
anchorLeft: anchorBounds.left,
anchorTop: anchorBounds.top,
anchorWidth: anchorBounds.width,
panelHeight: panelBounds.height,
panelWidth: panelBounds.width,
winHeight: innerHeight,
winWidth: innerWidth,
winScrollX: scrollX,
winScrollY: scrollY,
});
}, 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 > winWidth ? "right" : "left";
}
leftOffset = align === "left" ? leftAlignX : rightAlignX;
let bottomAlignY = anchorTop + anchorHeight;
let valign;
let topOffset;
if (bottomAlignY + panelHeight > winHeight) {
topOffset = anchorTop - panelHeight;
valign = "top";
} else {
topOffset = bottomAlignY;
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 is intended for inspection in Storybook.
return;
}
// Hide when a panel-item is clicked in the list.
this.addEventListener("click", this);
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);
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();
// 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);
// If the menu is opened with the mouse, the active element might be
// somewhere else in the document. In that case we should ignore it
// to avoid walking unrelated DOM nodes.
this.focusWalker.currentNode = this.contains(
this.getRootNode().activeElement
)
? this.getRootNode().activeElement
: this;
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 onShow() {
this.sendEvent("showing");
this.addHideListeners();
await this.setAlign();
// 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.currentNode = this;
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 }));
}
}
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 = window.IS_STORYBOOK
? "./panel-item.css"
: "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";
this.shadowRoot.append(
style,
this.button,
supportLinkSlot,
this.#defaultSlot
);
}
connectedCallback() {
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,
});
}
this.panel = this.closest("panel-list");
if (this.panel) {
this.panel.addEventListener("hidden", this);
this.panel.addEventListener("shown", this);
}
}
disconnectedCallback() {
if (this.panel) {
this.panel.removeEventListener("hidden", this);
this.panel.removeEventListener("shown", this);
this.panel = null;
}
}
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("");
}
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();
}
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;
}
}
}
customElements.define("panel-item", PanelItem);
}