857 lines
27 KiB
JavaScript
857 lines
27 KiB
JavaScript
/* 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(
|
|
`
|
|
<template>
|
|
<link rel="stylesheet" href=${cssPath}>
|
|
<div class="arrow top" role="presentation"></div>
|
|
<div class="list" role="presentation">
|
|
<slot></slot>
|
|
</div>
|
|
<div class="arrow bottom" role="presentation"></div>
|
|
</template>
|
|
`,
|
|
"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 {
|
|
// 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", "type", "disabled"];
|
|
}
|
|
|
|
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.#setButtonAttributes();
|
|
|
|
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._l10nRootConnected && document.l10n) {
|
|
document.l10n.connectRoot(this.shadowRoot);
|
|
this._l10nRootConnected = true;
|
|
}
|
|
|
|
this.panel =
|
|
this.getRootNode()?.host?.closest("panel-list") ||
|
|
this.closest("panel-list");
|
|
|
|
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 <slot>, unfortunately.
|
|
new MutationObserver(() => this.#setLabelContents()).observe(this, {
|
|
characterData: true,
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
|
|
if (this.hasSubmenu) {
|
|
this.panel.setAttribute("has-submenu", "");
|
|
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(this.submenuSlot);
|
|
|
|
this.setSubmenuContents();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
} else if (name === "type" || name === "disabled") {
|
|
this.#setButtonAttributes();
|
|
}
|
|
}
|
|
|
|
#setButtonAttributes() {
|
|
if (this.type == "checkbox") {
|
|
this.button.setAttribute("role", "menuitemcheckbox");
|
|
this.button.setAttribute("aria-checked", this.checked);
|
|
} else {
|
|
this.button.setAttribute("role", "menuitem");
|
|
this.button.removeAttribute("aria-checked");
|
|
}
|
|
this.button.toggleAttribute("disabled", this.disabled);
|
|
}
|
|
|
|
#setLabelContents() {
|
|
this.label.textContent = this.#defaultSlot
|
|
.assignedNodes()
|
|
.map(node => node.textContent)
|
|
.join("");
|
|
}
|
|
|
|
setSubmenuContents() {
|
|
this.submenuPanel = this.submenuSlot.assignedNodes()[0];
|
|
if (this.submenuPanel) {
|
|
this.shadowRoot.append(this.submenuPanel);
|
|
}
|
|
}
|
|
|
|
get disabled() {
|
|
return this.hasAttribute("disabled");
|
|
}
|
|
|
|
set disabled(val) {
|
|
this.toggleAttribute("disabled", val);
|
|
}
|
|
|
|
get checked() {
|
|
if (this.type !== "checkbox") {
|
|
return false;
|
|
}
|
|
return this.hasAttribute("checked");
|
|
}
|
|
|
|
set checked(val) {
|
|
if (this.type == "checkbox") {
|
|
this.toggleAttribute("checked", val);
|
|
this.button.setAttribute("aria-checked", !!val);
|
|
}
|
|
}
|
|
|
|
get type() {
|
|
return this.getAttribute("type") || "button";
|
|
}
|
|
|
|
set type(val) {
|
|
this.setAttribute("type", 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);
|
|
}
|