summaryrefslogtreecommitdiffstats
path: root/toolkit/content/widgets/panel-list
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/content/widgets/panel-list
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/content/widgets/panel-list')
-rw-r--r--toolkit/content/widgets/panel-list/README.stories.md231
-rw-r--r--toolkit/content/widgets/panel-list/panel-item.css96
-rw-r--r--toolkit/content/widgets/panel-list/panel-list.css59
-rw-r--r--toolkit/content/widgets/panel-list/panel-list.js836
-rw-r--r--toolkit/content/widgets/panel-list/panel-list.stories.mjs147
5 files changed, 1369 insertions, 0 deletions
diff --git a/toolkit/content/widgets/panel-list/README.stories.md b/toolkit/content/widgets/panel-list/README.stories.md
new file mode 100644
index 0000000000..b8800e2b5f
--- /dev/null
+++ b/toolkit/content/widgets/panel-list/README.stories.md
@@ -0,0 +1,231 @@
+# Panel Menu
+
+The `panel-list` and `panel-item` components work together to create a menu for
+in-content contexts. The basic structure is a `panel-list` with `panel-item`
+children and optional `hr` elements as separators. The `panel-list` will anchor
+itself to the target of the initiating event when opened with
+`panelList.toggle(event)`.
+
+Note: Nested menus are not currently supported. XUL is currently required to
+support accesskey underlining (although using `moz-label` could change that).
+Shortcuts are not displayed automatically in the `panel-item`.
+
+```html story
+<panel-list stay-open open>
+ <panel-item action="new" accesskey="N">New</panel-item>
+ <panel-item accesskey="O">Open</panel-item>
+ <hr />
+ <panel-item action="save" accesskey="S">Save</panel-item>
+ <hr />
+ <panel-item accesskey="Q">Quit</panel-item>
+</panel-list>
+```
+
+## Status
+
+Current status is listed as in-development since this is only intended for use
+within in-content contexts. XUL is still required for accesskey underlining, but
+could be migrated to use the `moz-label` component. This is a useful but
+historical element that could likely use some attention at the API level and to
+be brought up to our design systems standards.
+
+## When to use
+
+* When there are multiple options for something that would take too
+ much space with individual buttons.
+* When the actions are not frequently needed.
+* When you are within an in-content context.
+
+## When not to use
+
+* When there is only one action.
+* When the actions are frequently needed.
+* In the browser chrome, you probably want to use
+ [menupopup](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/menupopup.js)
+ or
+ [panel](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/panel.js)
+ instead.
+
+## Basic usage
+
+The source for `panel-list` can be found under
+[toolkit/content/widgets/panel-list.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/panel-list.js).
+You can find an examples of `panel-list` in use in the Firefox codebase in both
+[about:addons](https://searchfox.org/mozilla-central/source/toolkit/mozapps/extensions/content/aboutaddons.html#87,102,114)
+and the
+[migration-wizard](https://searchfox.org/mozilla-central/source/browser/components/migration/content/migration-dialog-window.html#18).
+
+`panel-list` will automatically be imported in chrome documents, both through
+markup and through JS with `document.createElement("panel-list")` or by cloning
+a template.
+
+```html
+<!-- This will import `panel-list` if needed in most cases. -->
+<panel-list></panel-list>
+```
+
+In non-chrome documents it can be imported into `.html`/`.xhtml` files:
+
+```html
+<script src="chrome://global/content/elements/panel-list.js"></script>
+```
+
+And used as follows:
+
+```html
+<panel-list>
+ <panel-item accesskey="N">New</panel-item>
+ <panel-item accesskey="O">Open</panel-item>
+ <hr />
+ <panel-item accesskey="S">Save</panel-item>
+ <hr />
+ <panel-item accesskey="Q">Quit</panel-item>
+</panel-list>
+```
+
+The `toggle` method takes the event you received on your anchor button and opens
+the menu attached to that element.
+
+```js
+anchorButton.addEventListener("mousedown", e => panelList.toggle(e));
+```
+
+Accesskeys are activated with the bare accesskey letter when the menu is opened.
+So for this example after opening the menu pressing `s` will fire a click event
+on the Save `panel-item`.
+
+Note: XUL is currently required for accesskey underlining, but can be [replaced
+with `moz-label`](https://bugzilla.mozilla.org/show_bug.cgi?id=1828741) later.
+
+### Fluent usage
+
+The `panel-item` expects to have text content set by fluent.
+
+```html
+<panel-list>
+ <panel-item data-l10n-id="menu-new"></panel-item>
+ <panel-item data-l10n-id="menu-save"></panel-item>
+</panel-list>
+```
+
+In which case your Fluent messages will look something like this:
+
+```
+menu-new = New
+ .accesskey = N
+menu-save = Save
+ .accesskey = S
+```
+
+## Advanced usage
+
+### Showing the menu
+
+By default the menu will be hidden. It is shown when the `open` attribute is
+set, but that won't position the menu by default.
+
+To trigger the auto-positioning of the menu, it should be opened or closed using
+the `toggle(event)` method.
+
+```js
+function onMenuButton(event) {
+ document.querySelector("panel-list").toggle(event);
+}
+```
+
+The `toggle(event)` method will use `event.target` as the anchor for the menu.
+
+To achieve the expected behaviour, the menu should open on `mousedown` for mouse
+events, and `click` for keyboard events. This can be accomplished by checking
+the `event.inputSource` property in chrome contexts or `event.detail` in
+non-chrome contexts (`event.detail` will be the click count which is `0` when a
+click is from the keyboard).
+
+```js
+function openMenu(event) {
+ if (
+ event.type == "mousedown" ||
+ event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD ||
+ !event.detail
+ ) {
+ document.querySelector("panel-list").toggle(event);
+ }
+}
+
+let menuButton = document.getElementById("open-menu-button");
+menuButton.addEventListener("mousedown", openMenu);
+menuButton.addEventListener("click", openMenu);
+```
+
+### Icons
+
+Icons can be added to the `panel-item`s by setting a `background-image` on
+`panel-item::part(button)`.
+
+```css
+panel-item[action="new"]::part(button) {
+ background-image: url("./new.svg");
+}
+
+panel-item[action="save"]::part(button) {
+ background-image: url("./save.svg");
+}
+```
+
+### Badging
+
+Icons may be badged by setting the `badged` attribute. This adds a dot next to
+the icon.
+
+```html
+<panel-list>
+ <panel-item action="new">New</panel-item>
+ <panel-item action="save" badged>Save</panel-item>
+</panel-list>
+```
+
+```html story
+<panel-list stay-open open>
+ <panel-item action="new">New</panel-item>
+ <panel-item action="save" badged>Save</panel-item>
+</panel-list>
+```
+
+### Matching anchor width
+
+When using the `panel-list` like a `select` dropdown, it's nice to have it match
+the size of the anchor button. You can see this in practice in the
+[Wide variant](?path=/story/ui-widgets-panel-list--wide) and the
+`migration-wizard`. Setting the `min-width-from-anchor` attribute will cause the
+menu to match its anchor's width when it is opened.
+
+```html
+<button class="current-selection">Apples</button>
+<panel-list min-width-from-anchor>
+ <panel-item>Apples</panel-list>
+ <panel-item>Bananas</panel-list>
+</panel-list>
+```
+
+### Usage in a XUL `panel`
+
+The "new" (as of early 2023) migration wizard uses the `panel-list` inside of a
+XUL `panel` element to let its contents escape its container dialog by creating
+an OS-level window. This can be useful if the menu could be larger than its
+container, however in chrome contexts you are likely better off using
+`menupopup`.
+
+By placing a `panel-list` inside of a XUL `panel` it will automatically defer
+its positioning responsibilities to the XUL `panel` and it will then be able to
+grow larger than its containing window if needed.
+
+```html
+<!-- Assuming we're in a XUL document. -->
+<panel>
+ <html:panel-list>
+ <html:panel-item>Apples</html:panel-item>
+ <html:panel-item>Apples</html:panel-item>
+ <html:panel-item>Apples</html:panel-item>
+ </html:panel-list>
+</panel>
+```
diff --git a/toolkit/content/widgets/panel-list/panel-item.css b/toolkit/content/widgets/panel-list/panel-item.css
new file mode 100644
index 0000000000..28ff8a072f
--- /dev/null
+++ b/toolkit/content/widgets/panel-list/panel-item.css
@@ -0,0 +1,96 @@
+/* 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/. */
+
+:host(:not([hidden])) {
+ display: flex;
+ align-items: center;
+}
+
+::slotted(a) {
+ margin-inline-end: 12px;
+}
+
+:host button {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+:host([checked]) button {
+ background-image: url("chrome://global/skin/icons/check.svg");
+}
+
+button {
+ background-color: transparent;
+ color: inherit;
+ background-position: 8px center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ border: none;
+ position: relative;
+ display: block;
+ font: inherit;
+ padding: 4px 8px;
+ padding-inline-start: 32px;
+ text-align: start;
+ width: 100%;
+}
+
+button:dir(rtl),
+button:-moz-locale-dir(rtl) {
+ background-position-x: right 8px;
+}
+
+:host([badged]) button::after {
+ content: "";
+ display: block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background-color: var(--in-content-accent-color);
+ position: absolute;
+ top: 4px;
+ inset-inline-start: 24px;
+}
+
+button:enabled:hover {
+ background-color: var(--in-content-button-background-hover);
+ color: var(--in-content-button-text-color-hover);
+}
+
+button:enabled:hover:active {
+ background-color: var(--in-content-button-background-active);
+ color: var(--in-content-button-text-color-active);
+}
+
+button:focus-visible {
+ outline-offset: var(--in-content-focus-outline-inset);
+}
+
+button:disabled {
+ opacity: 0.4;
+}
+
+.submenu-container {
+ display: flex;
+ flex-direction: row;
+}
+
+.submenu-icon {
+ display: inline-block;
+ background-image: url("chrome://global/skin/icons/arrow-right.svg");
+ background-position: center center;
+ background-repeat: no-repeat;
+ fill: currentColor;
+ width: var(--size-item-small);
+ height: var(--size-item-small);
+ flex: 1 1 auto;
+
+ &:dir(rtl) {
+ background-image: url("chrome://global/skin/icons/arrow-left.svg");
+ }
+}
+
+.submenu-label {
+ flex: 90% 1 0;
+}
diff --git a/toolkit/content/widgets/panel-list/panel-list.css b/toolkit/content/widgets/panel-list/panel-list.css
new file mode 100644
index 0000000000..4358fc0cf8
--- /dev/null
+++ b/toolkit/content/widgets/panel-list/panel-list.css
@@ -0,0 +1,59 @@
+/* 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/. */
+
+:host([showing]) {
+ visibility: hidden;
+}
+
+:host(:not([open])) {
+ display: none;
+}
+
+:host {
+ position: absolute;
+ font: menu;
+ background-color: var(--in-content-box-background);
+ border-radius: 4px;
+ padding: 6px 0;
+ margin-bottom: 16px;
+ box-shadow: var(--shadow-30);
+ min-width: 12em;
+ z-index: var(--z-index-popup, 10);
+ white-space: nowrap;
+ cursor: default;
+ overflow-y: auto;
+ box-sizing: border-box;
+}
+
+:host(:not([slot=submenu])) {
+ max-height: 100%;
+}
+
+:host([stay-open]) {
+ position: initial;
+ display: inline-block;
+}
+
+:host([inxulpanel]) {
+ position: static;
+ margin: 0;
+}
+
+:host(:not([inxulpanel])) {
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+}
+
+::slotted(hr:not([hidden])) {
+ display: block !important;
+ height: 1px !important;
+ background: var(--in-content-box-border-color) !important;
+ padding: 0 !important;
+ margin: 6px 0 !important;
+ border: none !important;
+}
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(
+ `
+ <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 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 <slot>, 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);
+}
diff --git a/toolkit/content/widgets/panel-list/panel-list.stories.mjs b/toolkit/content/widgets/panel-list/panel-list.stories.mjs
new file mode 100644
index 0000000000..9c5a4cbe1f
--- /dev/null
+++ b/toolkit/content/widgets/panel-list/panel-list.stories.mjs
@@ -0,0 +1,147 @@
+/* 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/. */
+
+// eslint-disable-next-line import/no-unassigned-import
+import "./panel-list.js";
+import { html, ifDefined } from "../vendor/lit.all.mjs";
+
+export default {
+ title: "UI Widgets/Panel List",
+ component: "panel-list",
+ parameters: {
+ status: "in-development",
+ actions: {
+ handles: ["showing", "shown", "hidden", "click"],
+ },
+ fluent: `
+panel-list-item-one = Item One
+panel-list-item-two = Item Two (accesskey w)
+panel-list-item-three = Item Three
+panel-list-checked = Checked
+panel-list-badged = Badged, look at me
+panel-list-passwords = Passwords
+panel-list-settings = Settings
+ `,
+ },
+};
+
+function openMenu(event) {
+ if (
+ event.type == "mousedown" ||
+ event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD ||
+ !event.detail
+ ) {
+ event.target.getRootNode().querySelector("panel-list").toggle(event);
+ }
+}
+
+const Template = ({ isOpen, items, wideAnchor }) =>
+ html`
+ <style>
+ panel-item[icon="passwords"]::part(button) {
+ background-image: url("chrome://browser/skin/login.svg");
+ }
+ panel-item[icon="settings"]::part(button) {
+ background-image: url("chrome://global/skin/icons/settings.svg");
+ }
+ button {
+ position: absolute;
+ background-image: url("chrome://global/skin/icons/more.svg");
+ }
+ button[wide] {
+ width: 400px !important;
+ }
+ .end {
+ inset-inline-end: 30px;
+ }
+
+ .bottom {
+ inset-block-end: 30px;
+ }
+ </style>
+ ${isOpen
+ ? ""
+ : html`
+ <button
+ class="ghost-button icon-button"
+ @click=${openMenu}
+ @mousedown=${openMenu}
+ ?wide="${wideAnchor}"
+ ></button>
+ <button
+ class="ghost-button icon-button end"
+ @click=${openMenu}
+ @mousedown=${openMenu}
+ ?wide="${wideAnchor}"
+ ></button>
+ <button
+ class="ghost-button icon-button bottom"
+ @click=${openMenu}
+ @mousedown=${openMenu}
+ ?wide="${wideAnchor}"
+ ></button>
+ <button
+ class="ghost-button icon-button bottom end"
+ @click=${openMenu}
+ @mousedown=${openMenu}
+ ?wide="${wideAnchor}"
+ ></button>
+ `}
+ <panel-list
+ ?stay-open=${isOpen}
+ ?open=${isOpen}
+ ?min-width-from-anchor=${wideAnchor}
+ >
+ ${items.map(i =>
+ i == "<hr>"
+ ? html` <hr /> `
+ : html`
+ <panel-item
+ icon=${i.icon ?? ""}
+ ?checked=${i.checked}
+ ?badged=${i.badged}
+ accesskey=${ifDefined(i.accesskey)}
+ data-l10n-id=${i.l10nId ?? i}
+ ></panel-item>
+ `
+ )}
+ </panel-list>
+ `;
+
+export const Simple = Template.bind({});
+Simple.args = {
+ isOpen: false,
+ wideAnchor: false,
+ items: [
+ "panel-list-item-one",
+ { l10nId: "panel-list-item-two", accesskey: "w" },
+ "panel-list-item-three",
+ "<hr>",
+ { l10nId: "panel-list-checked", checked: true },
+ { l10nId: "panel-list-badged", badged: true, icon: "settings" },
+ ],
+};
+
+export const Icons = Template.bind({});
+Icons.args = {
+ isOpen: false,
+ wideAnchor: false,
+ items: [
+ { l10nId: "panel-list-passwords", icon: "passwords" },
+ { l10nId: "panel-list-settings", icon: "settings" },
+ ],
+};
+
+export const Open = Template.bind({});
+Open.args = {
+ ...Simple.args,
+ wideAnchor: false,
+ isOpen: true,
+};
+
+export const Wide = Template.bind({});
+Wide.args = {
+ ...Simple.args,
+ wideAnchor: true,
+};