diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/content/widgets/panel-list | |
parent | Initial commit. (diff) | |
download | firefox-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.md | 231 | ||||
-rw-r--r-- | toolkit/content/widgets/panel-list/panel-item.css | 96 | ||||
-rw-r--r-- | toolkit/content/widgets/panel-list/panel-list.css | 59 | ||||
-rw-r--r-- | toolkit/content/widgets/panel-list/panel-list.js | 836 | ||||
-rw-r--r-- | toolkit/content/widgets/panel-list/panel-list.stories.mjs | 147 |
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, +}; |