diff options
Diffstat (limited to 'comm/mail/components/unifiedtoolbar')
47 files changed, 7643 insertions, 0 deletions
diff --git a/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs b/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs new file mode 100644 index 0000000000..e503306fde --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs @@ -0,0 +1,299 @@ +/* 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/. */ + +import CUSTOMIZABLE_ITEMS from "resource:///modules/CustomizableItemsDetails.mjs"; + +const { EXTENSION_PREFIX } = ChromeUtils.importESModule( + "resource:///modules/CustomizableItems.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); +const browserActionFor = extensionId => + lazy.ExtensionParent.apiManager.global.browserActionFor?.( + lazy.ExtensionParent.GlobalManager.getExtension(extensionId) + ); + +/** + * Wrapper element for elements whose position can be customized. + * + * Template ID: #unifiedToolbarCustomizableElementTemplate + * Attributes: + * - item-id: ID of the customizable item this represents. Not observed. + * - disabled: Gets passed on to the live content. + */ +export default class CustomizableElement extends HTMLLIElement { + static get observedAttributes() { + return ["disabled", "tabindex"]; + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "customizable-element"); + + const template = document + .getElementById("unifiedToolbarCustomizableElementTemplate") + .content.cloneNode(true); + + const itemId = this.getAttribute("item-id"); + + if (itemId.startsWith(EXTENSION_PREFIX)) { + const extensionId = itemId.slice(EXTENSION_PREFIX.length); + this.append(template); + this.#initializeForExtension(extensionId); + return; + } + + const details = CUSTOMIZABLE_ITEMS.find(item => item.id === itemId); + if (!details) { + throw new Error(`Could not find definition for ${itemId}`); + } + this.append(template); + this.#initializeFromDetails(details).catch(console.error); + } + + attributeChangedCallback(attribute) { + switch (attribute) { + case "disabled": { + const isDisabled = this.disabled; + for (const child of this.querySelector(".live-content")?.children ?? + []) { + child.toggleAttribute("disabled", isDisabled); + } + break; + } + case "tabindex": { + const tabIndex = this.getAttribute("tabindex"); + if (tabIndex === null) { + return; + } + if (this.details?.skipFocus && tabIndex !== "-1") { + this.removeAttribute("tabindex"); + // Let the container know that an element that shouldn't be focused is + // currently marked with a tabindex instruction. + if (this.hasConnected) { + this.dispatchEvent(new CustomEvent("buttondisabled")); + } + return; + } + const tabIndexNumber = parseInt(tabIndex, 10); + for (const child of this.querySelector(".live-content")?.children ?? + []) { + child.tabIndex = tabIndexNumber; + } + if (tabIndex !== "-1") { + this.removeAttribute("tabindex"); + } + break; + } + } + } + + /** + * Initialize the template contents from item details. Can't operate on the + * template directly due to being async. + * + * @param {CustomizableItemDetails} itemDetails + */ + async #initializeFromDetails(itemDetails) { + if (this.details) { + return; + } + this.details = itemDetails; + this.classList.add(itemDetails.id); + if (Array.isArray(itemDetails.requiredModules)) { + await Promise.all( + itemDetails.requiredModules.map(module => { + return import(module); // eslint-disable-line no-unsanitized/method + }) + ); + } + if (itemDetails.templateId) { + const contentTemplate = document.getElementById(itemDetails.templateId); + this.querySelector(".live-content").append( + contentTemplate.content.cloneNode(true) + ); + if (this.disabled) { + this.attributeChangedCallback("disabled"); + } + } + if (itemDetails.skipFocus) { + this.classList.add("skip-focus"); + } + if (this.hasAttribute("tabindex")) { + this.attributeChangedCallback("tabindex"); + } + // We need to manually re-emit this event, since it might've been emitted + // after we cloned the template. + if (this.querySelector(".live-content button[disabled]")) { + this.dispatchEvent(new CustomEvent("buttondisabled")); + } + document.l10n.setAttributes( + this.querySelector(".preview-label"), + `${itemDetails.labelId}-label` + ); + } + + /** + * Initialize the contents of this customizable element for a button from an + * extension. + * + * @param {string} extensionId - ID of the extension the button is from. + */ + async #initializeForExtension(extensionId) { + const extensionAction = browserActionFor(extensionId); + if (!extensionAction?.extension) { + return; + } + this.details = { + allowMultiple: false, + spaces: extensionAction.allowedSpaces ?? ["mail"], + }; + if (!customElements.get("extension-action-button")) { + await import("./extension-action-button.mjs"); + } + const { extension } = extensionAction; + this.classList.add("extension-action"); + const extensionButton = document.createElement("button", { + is: "extension-action-button", + }); + extensionButton.setAttribute("extension", extensionId); + this.querySelector(".live-content").append(extensionButton); + if (this.disabled) { + this.attributeChangedCallback("disabled"); + } + if (this.hasAttribute("tabindex")) { + this.attributeChangedCallback("tabindex"); + } + // We need to manually re-emit this event, since it might've been emitted + // before the button was attached to the DOM. + if (this.querySelector(".live-content button[disabled]")) { + this.dispatchEvent(new CustomEvent("buttondisabled")); + } + const previewLabel = this.querySelector(".preview-label"); + const labelText = extension.name || extensionId; + previewLabel.textContent = labelText; + previewLabel.title = labelText; + const { IconDetails } = lazy.ExtensionParent; + if (extension.manifest.icons) { + let { icon } = IconDetails.getPreferredIcon( + extension.manifest.icons, + extension, + 16 + ); + let { icon: icon2x } = IconDetails.getPreferredIcon( + extension.manifest.icons, + extension, + 32 + ); + this.style.setProperty( + "--webextension-icon", + `url("${lazy.ExtensionParent.IconDetails.escapeUrl(icon)}")` + ); + this.style.setProperty( + "--webextension-icon-2x", + `url("${lazy.ExtensionParent.IconDetails.escapeUrl(icon2x)}")` + ); + } + } + + /** + * Holds a reference to the palette this element belongs to. + * + * @type {CustomizationPalette} + */ + get palette() { + const paletteClass = this.details.spaces?.length + ? "space-specific-palette" + : "generic-palette"; + return this.getRootNode().querySelector(`.${paletteClass}`); + } + + /** + * If multiple instances of this element are allowed in the same space. + * + * @type {boolean} + */ + get allowMultiple() { + return Boolean(this.details?.allowMultiple); + } + + /** + * Human readable label for the widget. + * + * @type {string} + */ + get label() { + return this.querySelector(".preview-label").textContent; + } + + /** + * Calls onTabSwitched on the first button contained in the live content. + * No-op if this item is disabled. Called by unified-toolbar's tab monitor. + * + * @param {TabInfo} tab - Tab that is now selected. + * @param {TabInfo} oldTab - Tab that was selected before. + */ + onTabSwitched(tab, oldTab) { + if (this.disabled) { + return; + } + this.querySelector(".live-content button")?.onTabSwitched?.(tab, oldTab); + } + + /** + * Calls onTabClosing on the first button contained in the live content. + * No-op if this item is disabled. Called by unified-toolbar's tab monitor. + * + * @param {TabInfo} tab - Tab that was closed. + */ + onTabClosing(tab) { + if (this.disabled) { + return; + } + this.querySelector(".live-content button")?.onTabClosing?.(tab); + } + + /** + * If this item can be added to all spaces. + * + * @type {boolean} + */ + get allSpaces() { + return !this.details.spaces?.length; + } + + /** + * If this item wants to provide its own context menu. + * + * @type {boolean} + */ + get hasContextMenu() { + return Boolean(this.details?.hasContextMenu); + } + + /** + * @type {boolean} + */ + get disabled() { + return this.hasAttribute("disabled"); + } + + set disabled(value) { + this.toggleAttribute("disabled", value); + } + + focus() { + this.querySelector(".live-content *:first-child")?.focus(); + } +} +customElements.define("customizable-element", CustomizableElement, { + extends: "li", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs b/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs new file mode 100644 index 0000000000..d3d0417f7e --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs @@ -0,0 +1,243 @@ +/* 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/. */ + +import ListBoxSelection from "./list-box-selection.mjs"; +import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import + +const { getAvailableItemIdsForSpace, MULTIPLE_ALLOWED_ITEM_IDS } = + ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs"); + +/** + * Customization palette containing items that can be added to a customization + * target. + * Attributes: + * - space: ID of the space the widgets are for. "all" for space agnostic + * widgets. Not observed. + * - items-in-use: Comma-separated IDs of items that are in a target at the time + * this is initialized. When changed, initialize should be called. + */ +class CustomizationPalette extends ListBoxSelection { + contextMenuId = "customizationPaletteMenu"; + + /** + * If this palette contains items (even if those items are currently all in a + * target). + * + * @type {boolean} + */ + isEmpty = false; + + /** + * Array of item IDs allowed to be in this palette. + * + * @type {string[]} + */ + #allAvailableItems = []; + + /** + * If this palette contains items that can be added to all spaces. + * + * @type {boolean} + */ + #allSpaces = false; + + connectedCallback() { + if (super.connectedCallback()) { + return; + } + + this.#allSpaces = this.getAttribute("space") === "all"; + + if (this.#allSpaces) { + document + .getElementById("customizationPaletteAddEverywhere") + .addEventListener("command", this.#handleMenuAddEverywhere); + } + + this.initialize(); + } + + /** + * Initializes the contents of the palette from the current state. The + * relevant state is defined by the space and items-in-use attributes. + */ + initialize() { + const itemIds = this.getAttribute("items-in-use").split(","); + this.setItems(itemIds); + } + + /** + * Update the items currently removed from the palette with an array of item + * IDs. + * + * @param {string[]} itemIds - Array of item IDs currently being used in a + * target. + */ + setItems(itemIds) { + let space = this.getAttribute("space"); + if (space === "all") { + space = undefined; + } + const itemsInUse = new Set(itemIds); + this.#allAvailableItems = getAvailableItemIdsForSpace(space); + this.isEmpty = !this.#allAvailableItems.length; + const items = this.#allAvailableItems.filter( + itemId => !itemsInUse.has(itemId) || MULTIPLE_ALLOWED_ITEM_IDS.has(itemId) + ); + this.replaceChildren( + ...items.map(itemId => { + const element = document.createElement("li", { + is: "customizable-element", + }); + element.setAttribute("item-id", itemId); + element.draggable = true; + return element; + }) + ); + } + + /** + * Overwritten context menu handler. Before showing the menu, initializes the + * menu with items for all the target areas available. + * + * @param {MouseEvent} event + */ + handleContextMenu = event => { + const menu = document.getElementById(this.contextMenuId); + const targets = this.getRootNode().querySelectorAll( + '[is="customization-target"]' + ); + const addEverywhereItem = document.getElementById( + "customizationPaletteAddEverywhere" + ); + addEverywhereItem.setAttribute("hidden", (!this.#allSpaces).toString()); + const menuItems = Array.from(targets, target => { + const menuItem = document.createXULElement("menuitem"); + document.l10n.setAttributes(menuItem, "customize-palette-add-to", { + target: target.name, + }); + menuItem.addEventListener( + "command", + this.#makeAddToTargetHandler(target) + ); + return menuItem; + }); + menuItems.push(addEverywhereItem); + menu.replaceChildren(...menuItems); + this.initializeContextMenu(event); + }; + + #handleMenuAddEverywhere = () => { + if (this.contextMenuFor) { + this.primaryAction(this.contextMenuFor); + this.dispatchEvent( + new CustomEvent("additem", { + detail: { + itemId: this.contextMenuFor.getAttribute("item-id"), + }, + bubbles: true, + composed: true, + }) + ); + } + }; + + /** + * Generate a context menu item event handler that will add the right clicked + * item to the target. + * + * @param {CustomizationTarget} target + * @returns {function} Context menu item event handler curried with the given + * target. + */ + #makeAddToTargetHandler(target) { + return () => { + if (this.contextMenuFor) { + this.primaryAction(this.contextMenuFor, target); + } + }; + } + + handleDragSuccess(item) { + if (item.allowMultiple) { + return; + } + super.handleDragSuccess(item); + } + + handleDrop(itemId, sibling, afterSibling) { + if (this.querySelector(`li[item-id="${itemId}"]`)?.allowMultiple) { + return; + } + super.handleDrop(itemId, sibling, afterSibling); + } + + canAddElement(itemId) { + return ( + this.#allAvailableItems.includes(itemId) && + (super.canAddElement(itemId) || + this.querySelector(`li[item-id="${itemId}"]`).allowMultiple) + ); + } + + /** + * The primary action for the palette is to add the item to a customization + * target. Will pick the first target if none is provided. + * + * @param {CustomizableElement} item - Item to move to a target. + * @param {CustomizationTarget} [target] - The target to move the item to. + * Defaults to the first target in the root. + */ + primaryAction(item, target) { + if (!target) { + target = this.getRootNode().querySelector('[is="customization-target"]'); + } + if (item?.allowMultiple) { + target.addItem(item.cloneNode(true)); + return; + } + if (super.primaryAction(item)) { + return; + } + target.addItem(item); + } + + /** + * Returns the item to this palette from some other place. + * + * @param {CustomizableElement} item - Item to return to this palette. + */ + returnItem(item) { + if (item.allowMultiple) { + item.remove(); + return; + } + this.append(item); + } + + /** + * Filter the items in the palette for the given string based on their label. + * The comparison is done on the lower cased label, and the filter string is + * lower cased as well. + * + * @param {string} filterString - String to filter the items by. + */ + filterItems(filterString) { + const lowerFilterString = filterString.toLowerCase(); + for (const item of this.children) { + item.hidden = !item.label.toLowerCase().includes(lowerFilterString); + } + } + + addItemById(itemId) { + const item = this.querySelector(`[item-id="${itemId}"]`); + if (!item) { + return; + } + this.primaryAction(item); + } +} +customElements.define("customization-palette", CustomizationPalette, { + extends: "ul", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/customization-target.mjs b/comm/mail/components/unifiedtoolbar/content/customization-target.mjs new file mode 100644 index 0000000000..1ea5f67160 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/customization-target.mjs @@ -0,0 +1,333 @@ +/* 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/. */ + +import ListBoxSelection from "./list-box-selection.mjs"; +import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import + +const { getAvailableItemIdsForSpace } = ChromeUtils.importESModule( + "resource:///modules/CustomizableItems.sys.mjs" +); + +/** + * Customization target where items can be placed, rearranged and removed. + * Attributes: + * - aria-label: Name of the target area. + * - current-items: Comma separated item IDs currently in this area. When + * changed initialize should be called. + * Events: + * - itemchange: Fired whenever the items inside the toolbar are added, moved or + * removed. + * - space: The space this target is in. + */ +class CustomizationTarget extends ListBoxSelection { + contextMenuId = "customizationTargetMenu"; + actionKey = "Delete"; + canMoveItems = true; + + connectedCallback() { + if (super.connectedCallback()) { + return; + } + + document + .getElementById("customizationTargetForward") + .addEventListener("command", this.#handleMenuForward); + document + .getElementById("customizationTargetBackward") + .addEventListener("command", this.#handleMenuBackward); + document + .getElementById("customizationTargetRemove") + .addEventListener("command", this.#handleMenuRemove); + document + .getElementById("customizationTargetRemoveEverywhere") + .addEventListener("command", this.#handleMenuRemoveEverywhere); + document + .getElementById("customizationTargetAddEverywhere") + .addEventListener("command", this.#handleMenuAddEverywhere); + document + .getElementById("customizationTargetStart") + .addEventListener("command", this.#handleMenuStart); + document + .getElementById("customizationTargetEnd") + .addEventListener("command", this.#handleMenuEnd); + + this.initialize(); + } + + /** + * Initialize the contents of the target from the current state. The relevant + * state is passed in via the current-items attribute. + */ + initialize() { + const itemIds = this.getAttribute("current-items").split(","); + this.setItems(itemIds); + } + + /** + * Update the items in the target from an array of item IDs. + * + * @param {string[]} itemIds - ordered array of IDs of the items currently in + * the target + */ + setItems(itemIds) { + const childCount = this.children.length; + const availableItems = getAvailableItemIdsForSpace( + this.getAttribute("space"), + true + ); + this.replaceChildren( + ...itemIds.map(itemId => { + const element = document.createElement("li", { + is: "customizable-element", + }); + element.setAttribute("item-id", itemId); + element.setAttribute("disabled", "disabled"); + element.classList.toggle("collapsed", !availableItems.includes(itemId)); + element.draggable = true; + return element; + }) + ); + if (childCount) { + this.#onChange(); + } + } + + /** + * Human-readable name of the customization target area. + * + * @type {string} + */ + get name() { + return this.getAttribute("aria-label"); + } + + handleContextMenu = event => { + this.initializeContextMenu(event); + const notForAllSpaces = !this.contextMenuFor.allSpaces; + const removeEverywhereItem = document.getElementById( + "customizationTargetRemoveEverywhere" + ); + const addEverywhereItem = document.getElementById( + "customizationTargetAddEverywhere" + ); + addEverywhereItem.setAttribute("hidden", notForAllSpaces.toString()); + removeEverywhereItem.setAttribute("hidden", notForAllSpaces.toString()); + if (!notForAllSpaces) { + const customization = this.getRootNode().host.closest( + "unified-toolbar-customization" + ); + const itemId = this.contextMenuFor.getAttribute("item-id"); + addEverywhereItem.disabled = + !this.contextMenuFor.allowMultiple && + customization.activeInAllSpaces(itemId); + removeEverywhereItem.disabled = + this.contextMenuFor.allowMultiple || + !customization.activeInMultipleSpaces(itemId); + } + const isFirstElement = this.contextMenuFor === this.firstElementChild; + const isLastElement = this.contextMenuFor === this.lastElementChild; + document.getElementById("customizationTargetBackward").disabled = + isFirstElement; + document.getElementById("customizationTargetForward").disabled = + isLastElement; + document.getElementById("customizationTargetStart").disabled = + isFirstElement; + document.getElementById("customizationTargetEnd").disabled = isLastElement; + }; + + /** + * Event handler when the context menu item to move the item forward is + * selected. + */ + #handleMenuForward = () => { + if (this.contextMenuFor) { + this.moveItemForward(this.contextMenuFor); + } + }; + + /** + * Event handler when the context menu item to move the item backward is + * selected. + */ + #handleMenuBackward = () => { + if (this.contextMenuFor) { + this.moveItemBackward(this.contextMenuFor); + } + }; + + /** + * Event handler when the context menu item to remove the item is selected. + */ + #handleMenuRemove = () => { + if (this.contextMenuFor) { + this.primaryAction(this.contextMenuFor); + } + }; + + #handleMenuRemoveEverywhere = () => { + if (this.contextMenuFor) { + this.primaryAction(this.contextMenuFor); + this.dispatchEvent( + new CustomEvent("removeitem", { + detail: { + itemId: this.contextMenuFor.getAttribute("item-id"), + }, + bubbles: true, + composed: true, + }) + ); + } + }; + + #handleMenuAddEverywhere = () => { + if (this.contextMenuFor) { + this.dispatchEvent( + new CustomEvent("additem", { + detail: { + itemId: this.contextMenuFor.getAttribute("item-id"), + }, + bubbles: true, + composed: true, + }) + ); + } + }; + + #handleMenuStart = () => { + if (this.contextMenuFor) { + this.moveItemToStart(this.contextMenuFor); + } + }; + + #handleMenuEnd = () => { + if (this.contextMenuFor) { + this.moveItemToEnd(this.contextMenuFor); + } + }; + + /** + * Emit a change event. Should be called whenever items are added, moved or + * removed from the target. + */ + #onChange() { + const changeEvent = new Event("itemchange", { + bubbles: true, + // Make sure this bubbles out of the pane shadow root. + composed: true, + }); + this.dispatchEvent(changeEvent); + } + + /** + * Adopt an item from another list into this one. + * + * @param {?CustomizableElement} item - Item from another list. + */ + #adoptItem(item) { + item?.setAttribute("disabled", "disabled"); + } + + moveItemForward(...args) { + super.moveItemForward(...args); + this.#onChange(); + } + + moveItemBackward(...args) { + super.moveItemBackward(...args); + this.#onChange(); + } + + moveItemToStart(...args) { + super.moveItemToStart(...args); + this.#onChange(); + } + + moveItemToEnd(...args) { + super.moveItemToEnd(...args); + this.#onChange(); + } + + handleDrop(itemId, sibling, afterSibling) { + const item = super.handleDrop(itemId, sibling, afterSibling); + if (item) { + this.#adoptItem(item); + this.#onChange(); + } + } + + handleDragSuccess(item) { + super.handleDragSuccess(item); + this.#onChange(); + } + + /** + * Return the item to its palette, removing it from this target. + * + * @param {CustomizableElement} item - The item to remove. + */ + primaryAction(item) { + if (super.primaryAction(item)) { + return; + } + item.palette.returnItem(item); + this.#onChange(); + } + + /** + * Add an item to the end of this customization target. + * + * @param {CustomizableElement} item - The item to add. + */ + addItem(item) { + if (!item) { + return; + } + this.#adoptItem(item); + this.append(item); + this.#onChange(); + } + + removeItemById(itemId) { + const item = this.querySelector(`[item-id="${itemId}"]`); + if (!item) { + return; + } + this.primaryAction(item); + } + + /** + * Check if an item is currently used in this target. + * + * @param {string} itemId - Item ID of the item to check for. + * @returns {boolean} If the item is currently used in this target. + */ + hasItem(itemId) { + return Boolean(this.querySelector(`[item-id="${itemId}"]`)); + } + + /** + * IDs of the items currently in this target, in correct order including + * duplicates. + * + * @type {string[]} + */ + get itemIds() { + return Array.from(this.children, element => + element.getAttribute("item-id") + ); + } + + /** + * If the contents of this target differ from the currently saved + * configuration. + * + * @type {boolean} + */ + get hasChanges() { + return this.itemIds.join(",") !== this.getAttribute("current-items"); + } +} +customElements.define("customization-target", CustomizationTarget, { + extends: "ul", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs b/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs new file mode 100644 index 0000000000..cc833aae62 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs @@ -0,0 +1,148 @@ +/* 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/. */ + +import { UnifiedToolbarButton } from "./unified-toolbar-button.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); +let browserActionFor = extensionId => { + const extension = + lazy.ExtensionParent.GlobalManager.getExtension(extensionId); + if (!extension) { + return null; + } + return lazy.ExtensionParent.apiManager.global.browserActionFor(extension); +}; + +const BADGE_BACKGROUND_COLOR = "--toolbar-button-badge-bg-color"; + +/** + * Attributes: + * - extension: ID of the extension this button is for. + * - open: true if the popup is currently open. Gets redirected to aria-pressed. + */ +class ExtensionActionButton extends UnifiedToolbarButton { + static get observedAttributes() { + return super.observedAttributes.concat("open"); + } + + /** + * ext-browserAction instance for this button. + * + * @type {?ToolbarButtonAPI} + */ + #action = null; + + connectedCallback() { + if (this.hasConnected) { + super.connectedCallback(); + if (this.#action?.extension?.hasPermission("menus")) { + document.addEventListener("popupshowing", this.#action); + } + return; + } + super.connectedCallback(); + this.#action = browserActionFor(this.getAttribute("extension")); + if (!this.#action) { + return; + } + const contextData = this.#action.getContextData( + this.#action.getTargetFromWindow(window) + ); + this.applyTabData(contextData); + if (this.#action.extension.hasPermission("menus")) { + document.addEventListener("popupshowing", this.#action); + if (this.#action.defaults.type == "menu") { + let menupopup = document.createXULElement("menupopup"); + menupopup.dataset.actionMenu = this.#action.manifestName; + menupopup.dataset.extensionId = this.#action.extension.id; + menupopup.addEventListener("popuphiding", event => { + if (event.target.state === "open") { + return; + } + this.removeAttribute("aria-pressed"); + }); + this.appendChild(menupopup); + } + } + } + + disconnectedCallback() { + if (this.#action?.extension?.hasPermission("menus")) { + document.removeEventListener("popupshowing", this.#action); + } + } + + attributeChangedCallback(attribute) { + super.attributeChangedCallback(attribute); + if (attribute === "open") { + if (this.getAttribute("open") === "true") { + this.setAttribute("aria-pressed", "true"); + } else { + this.removeAttribute("aria-pressed"); + } + } + } + + /** + * Apply the data for the current tab to the extension button. Updates title, + * label, icon, badge, disabled and popup. + * + * @param {object} tabData - Properties for the button in the current tab. See + * ExtensionToolbarButtons.jsm for more details. + */ + applyTabData(tabData) { + if (!this.#action) { + this.#action = browserActionFor(this.getAttribute("extension")); + } + this.title = tabData.title || this.#action.extension.name; + this.setAttribute("label", tabData.label || this.title); + this.classList.toggle("prefer-icon-only", tabData.label == ""); + this.badge = tabData.badgeText; + this.disabled = !tabData.enabled; + const { style } = this.#action.iconData.get(tabData.icon); + for (const [propName, value] of style) { + this.style.setProperty(propName, value); + } + if (tabData.badgeText && tabData.badgeBackgroundColor) { + const bgColor = tabData.badgeBackgroundColor; + this.style.setProperty( + BADGE_BACKGROUND_COLOR, + `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})` + ); + } else { + this.style.removeProperty(BADGE_BACKGROUND_COLOR); + } + this.toggleAttribute("popup", tabData.popup || tabData.type == "menu"); + if (!tabData.popup) { + this.removeAttribute("aria-pressed"); + } + } + + handleClick = event => { + // If there is a menupopup associated with this button, open it, instead of + // executing the click action. + const menupopup = this.querySelector("menupopup"); + if (menupopup) { + event.preventDefault(); + event.stopPropagation(); + menupopup.openPopup(this, { + position: "after_start", + triggerEvent: event, + }); + this.setAttribute("aria-pressed", "true"); + return; + } + this.#action?.handleEvent(event); + }; + + handlePopupShowing(event) { + this.#action.handleEvent(event); + } +} +customElements.define("extension-action-button", ExtensionActionButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs new file mode 100644 index 0000000000..9fe0aef11d --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs @@ -0,0 +1,38 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +/* import-globals-from ../../../../../calendar/base/content/calendar-extract.js */ + +/** + * Unified toolbar button to add the selected message to a calendar as event or + * task. + * Attributes: + * - type: "event" or "task", specifying the target type to create. + */ +class AddToCalendarButton extends MailTabButton { + onCommandContextChange() { + const about3Pane = document.getElementById("tabmail").currentAbout3Pane; + this.disabled = + (about3Pane && !about3Pane.gDBView) || + (about3Pane?.gDBView?.numSelected ?? -1) === 0; + } + + handleClick = event => { + const tabmail = document.getElementById("tabmail"); + const about3Pane = tabmail.currentAbout3Pane; + const type = this.getAttribute("type"); + calendarExtract.extractFromEmail( + tabmail.currentAboutMessage?.gMessage || + about3Pane.gDBView.hdrForFirstSelectedMessage, + type !== "task" + ); + event.preventDefault(); + event.stopPropagation(); + }; +} +customElements.define("add-to-calendar-button", AddToCalendarButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs new file mode 100644 index 0000000000..593513cd35 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs @@ -0,0 +1,19 @@ +/* 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/. */ + +import { UnifiedToolbarButton } from "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs"; + +/** + * Unified toolbar button that opens the add-ons manager. + */ +class AddonsButton extends UnifiedToolbarButton { + handleClick = event => { + window.openAddonsMgr(); + event.preventDefault(); + event.stopPropagation(); + }; +} +customElements.define("addons-button", AddonsButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs new file mode 100644 index 0000000000..78abbaef3a --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs @@ -0,0 +1,40 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +/** + * Unified toolbar button for compacting the current folder. + */ +class CompactFolderButton extends MailTabButton { + observed3PaneEvents = ["folderURIChanged"]; + observedAboutMessageEvents = []; + + onCommandContextChange() { + const { gFolder } = + document.getElementById("tabmail").currentAbout3Pane ?? {}; + if (!gFolder) { + this.disabled = true; + return; + } + try { + this.disabled = !gFolder.isCommandEnabled("cmd_compactFolder"); + } catch { + this.disabled = true; + } + } + + handleClick = event => { + const about3Pane = document.getElementById("tabmail").currentAbout3Pane; + if (!about3Pane) { + return; + } + about3Pane.folderPane.compactFolder(about3Pane.gFolder); + event.preventDefault(); + event.stopPropagation(); + }; +} +customElements.define("compact-folder-button", CompactFolderButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs new file mode 100644 index 0000000000..02b8bb8035 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs @@ -0,0 +1,44 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +/* import-globals-from ../../../../base/content/globalOverlay.js */ + +/** + * Unified toolbar button that deletes the selected message or folder. + */ +class DeleteButton extends MailTabButton { + onCommandContextChange() { + const tabmail = document.getElementById("tabmail"); + try { + const controller = getEnabledControllerForCommand("cmd_deleteMessage"); + const tab = tabmail.currentTabInfo; + const message = tab.message; + + this.disabled = !controller || !message; + + if (!this.disabled && message.flags & Ci.nsMsgMessageFlags.IMAPDeleted) { + this.setAttribute("label-id", "toolbar-undelete-label"); + document.l10n.setAttributes(this, "toolbar-undelete"); + } else { + this.setAttribute("label-id", "toolbar-delete-label"); + document.l10n.setAttributes(this, "toolbar-delete-title"); + } + } catch { + this.disabled = true; + } + } + + handleClick(event) { + goDoCommand( + event.shiftKey ? "cmd_shiftDeleteMessage" : "cmd_deleteMessage" + ); + event.preventDefault(); + event.stopPropagation(); + } +} +customElements.define("delete-button", DeleteButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs new file mode 100644 index 0000000000..9d99dbbf30 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs @@ -0,0 +1,81 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "FolderUtils", + "resource:///modules/FolderUtils.jsm" +); + +class FolderLocationButton extends MailTabButton { + /** + * Image element displaying the icon on the button. + * + * @type {Image?} + */ + #icon = null; + + /** + * If we've added our event listeners, especially to the current about3pane. + * + * @type {boolean} + */ + #addedListeners = false; + + observed3PaneEvents = ["folderURIChanged"]; + + observedAboutMessageEvents = []; + + connectedCallback() { + super.connectedCallback(); + if (this.#addedListeners) { + return; + } + this.#icon = this.querySelector(".button-icon"); + this.onCommandContextChange(); + this.#addedListeners = true; + const popup = document.getElementById(this.getAttribute("popup")); + popup.addEventListener("command", this.#handlePopupCommand); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#addedListeners) { + const popup = document.getElementById(this.getAttribute("popup")); + popup.removeEventListener("command", this.#handlePopupCommand); + } + } + + #handlePopupCommand = event => { + const about3Pane = document.getElementById("tabmail").currentAbout3Pane; + about3Pane.displayFolder(event.target._folder.URI); + }; + + /** + * Update the label and icon of the button from the currently selected folder + * in the local 3pane. + */ + onCommandContextChange() { + if (!this.#icon) { + return; + } + const { gFolder } = + document.getElementById("tabmail").currentAbout3Pane ?? {}; + if (!gFolder) { + this.disabled = true; + return; + } + this.disabled = false; + this.label.textContent = gFolder.name; + this.#icon.style = `content: url(${lazy.FolderUtils.getFolderIcon( + gFolder + )});`; + } +} +customElements.define("folder-location-button", FolderLocationButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs b/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs new file mode 100644 index 0000000000..924955c895 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs @@ -0,0 +1,223 @@ +/* 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/. */ + +import { SearchBar } from "chrome://messenger/content/unifiedtoolbar/search-bar.mjs"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GlodaIMSearcher: "resource:///modules/GlodaIMSearcher.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "GlodaMsgSearcher", + "resource:///modules/gloda/GlodaMsgSearcher.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "GlodaConstants", + "resource:///modules/gloda/GlodaConstants.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "Gloda", + "resource:///modules/gloda/GlodaPublic.jsm" +); +XPCOMUtils.defineLazyGetter( + lazy, + "glodaCompleter", + () => + Cc["@mozilla.org/autocomplete/search;1?name=gloda"].getService( + Ci.nsIAutoCompleteSearch + ).wrappedJSObject +); + +/** + * Unified toolbar global search bar. + */ +class GlobalSearchBar extends SearchBar { + // Fields required for the auto complete popup to work. + + get popup() { + return document.getElementById("PopupGlodaAutocomplete"); + } + + controller = { + matchCount: 0, + searchString: "", + stopSearch() { + lazy.glodaCompleter.stopSearch(); + }, + handleEnter: (isAutocomplete, event) => { + if (!isAutocomplete) { + return; + } + this.#handleSearch({ detail: this.controller.searchString }); + this.reset(); + }, + }; + + _focus() { + this.focus(); + } + + #searchResultListener = { + onSearchResult: (result, search) => { + this.controller.matchCount = search.matchCount; + if (this.controller.matchCount < 1) { + this.popup.closePopup(); + return; + } + if (!this.popup.mPopupOpen) { + this.popup.openAutocompletePopup( + this, + this.shadowRoot.querySelector("input") + ); + return; + } + this.popup.invalidate(); + }, + }; + + // Normal custom element stuff + + connectedCallback() { + if (this.shadowRoot) { + return; + } + if ( + !Services.prefs.getBoolPref( + "mailnews.database.global.indexer.enabled", + true + ) + ) { + return; + } + // Need to call this after the shadow root test, since this will always set + // up a shadow root. + super.connectedCallback(); + this.addEventListener("search", this.#handleSearch); + this.addEventListener("autocomplete", this.#handleAutocomplete); + // Capturing to avoid the default cursor movements inside the input. + this.addEventListener("keydown", this.#handleKeydown, { + capture: true, + }); + this.addEventListener("focus", this.#handleFocus); + this.addEventListener("blur", this); + this.addEventListener("drop", this.#handleDrop, { capture: true }); + } + + handleEvent(event) { + switch (event.type) { + case "blur": + if (this.popup.mPopupOpen) { + this.popup.closePopup(); + } + break; + } + } + + #handleSearch = event => { + let tabmail = document.getElementById("tabmail"); + let args; + // Build the query from the autocomplete result. + const selectedIndex = this.popup.selectedIndex; + if (selectedIndex > -1) { + const curResult = lazy.glodaCompleter.curResult; + if (curResult) { + const row = curResult.getObjectAt(selectedIndex); + if (row && !row.fullText && row.nounDef) { + let query = lazy.Gloda.newQuery(lazy.GlodaConstants.NOUN_MESSAGE); + switch (row.nounDef.name) { + case "tag": + query = query.tags(row.item); + break; + case "identity": + query = query.involves(row.item); + break; + } + query.orderBy("-date"); + args = { query }; + } + } + } + // Or just do a normal full text search. + if (!args) { + let searchString = event.detail; + args = { + searcher: new lazy.GlodaMsgSearcher(null, searchString), + }; + if (Services.prefs.getBoolPref("mail.chat.enabled")) { + args.IMSearcher = new lazy.GlodaIMSearcher(null, searchString); + } + } + tabmail.openTab("glodaFacet", args); + this.popup.closePopup(); + this.controller.matchCount = 0; + this.controller.searchString = ""; + }; + + #handleAutocomplete = event => { + this.controller.searchString = event.detail; + if (!event.detail) { + this.popup.closePopup(); + this.controller.matchCount = 0; + return; + } + lazy.glodaCompleter.startSearch( + this.controller.searchString, + "global", + null, + this.#searchResultListener + ); + }; + + #handleKeydown = event => { + if (event.ctrlKey) { + return; + } + if (event.key == "ArrowDown") { + if (this.popup.selectedIndex < this.controller.matchCount - 1) { + ++this.popup.selectedIndex; + event.preventDefault(); + return; + } + this.popup.selectedIndex = -1; + event.preventDefault(); + return; + } + if (event.key == "ArrowUp") { + if (this.popup.selectedIndex > -1) { + --this.popup.selectedIndex; + event.preventDefault(); + return; + } + this.popup.selectedIndex = this.controller.matchCount - 1; + event.preventDefault(); + } + }; + + #handleFocus = event => { + if (this.controller.searchString && this.controller.matchCount >= 1) { + this.popup.openAutocompletePopup( + this, + this.shadowRoot.querySelector("input") + ); + } + }; + + #handleDrop = event => { + if (event.dataTransfer.types.includes("text/x-moz-address")) { + const searchTerm = event.dataTransfer.getData("text/plain"); + this.#handleSearch({ detail: searchTerm }); + } + event.stopPropagation(); + event.preventDefault(); + }; +} +customElements.define("global-search-bar", GlobalSearchBar); diff --git a/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs new file mode 100644 index 0000000000..df9266d077 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs @@ -0,0 +1,183 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +/** + * Map from the direction attribute value to the command the button executes on + * click. + * + * @type {{[string]: string}} + */ +const COMMAND_FOR_DIRECTION = { + forward: "cmd_goForward", + back: "cmd_goBack", +}; + +/** + * Unified toolbar button to add the selected message to a calendar as event or + * task. + * Attributes: + * - direction: "forward" or "back". + */ +class MailGoButton extends MailTabButton { + /** + * @type {?XULPopupElement} + */ + #contextMenu = null; + + connectedCallback() { + if (!this.hasConnected) { + const command = COMMAND_FOR_DIRECTION[this.getAttribute("direction")]; + if (!command) { + throw new Error( + `Unknown direction "${this.getAttribute("direction")}"` + ); + } + this.setAttribute("command", command); + this.#contextMenu = document.getElementById("messageHistoryPopup"); + this.addEventListener("contextmenu", this.#handleContextMenu, true); + } + super.connectedCallback(); + } + + /** + * Build and show the history popup containing a list of messages to navigate + * to. Messages that can't be found or that were in folders we can't find are + * ignored. The currently displayed message is marked. + * + * @param {MouseEvent} event - Event triggering the context menu. + */ + #handleContextMenu = event => { + event.preventDefault(); + event.stopPropagation(); + + const tabmail = document.getElementById("tabmail"); + const currentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow; + const { messageHistory } = tabmail.currentAboutMessage; + const { entries, currentIndex } = messageHistory.getHistory(); + + // For populating the back menu, we want the most recently visited + // messages first in the menu. So we go backward from curPos to 0. + // For the forward menu, we want to go forward from curPos to the end. + const items = []; + const relativePositionBase = entries.length - 1 - currentIndex; + for (const [index, entry] of entries.reverse().entries()) { + const folder = MailServices.folderLookup.getFolderForURL(entry.folderURI); + if (!folder) { + // Where did the folder go? + continue; + } + + let menuText = ""; + let msgHdr; + try { + msgHdr = MailServices.messageServiceFromURI( + entry.messageURI + ).messageURIToMsgHdr(entry.messageURI); + } catch (ex) { + // Let's just ignore this history entry. + continue; + } + const messageSubject = msgHdr.mime2DecodedSubject; + const messageAuthor = msgHdr.mime2DecodedAuthor; + + if (!messageAuthor && !messageSubject) { + // Avoid empty entries in the menu. The message was most likely (re)moved. + continue; + } + + // If the message was not being displayed via the current folder, prepend + // the folder name. We do not need to check underlying folders for + // virtual folders because 'folder' is the display folder, not the + // underlying one. + if (folder != currentWindow.gFolder) { + menuText = folder.prettyName + " - "; + } + + let subject = ""; + if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) { + subject = "Re: "; + } + if (messageSubject) { + subject += messageSubject; + } + if (subject) { + menuText += subject + " - "; + } + + menuText += messageAuthor; + const newMenuItem = document.createXULElement("menuitem"); + newMenuItem.setAttribute("label", menuText); + const relativePosition = relativePositionBase - index; + newMenuItem.setAttribute("value", relativePosition); + newMenuItem.addEventListener("command", commandEvent => { + this.#navigateToUri(commandEvent.target); + commandEvent.stopPropagation(); + }); + if (relativePosition === 0 && !messageHistory.canPop(0)) { + newMenuItem.setAttribute("checked", true); + newMenuItem.setAttribute("type", "radio"); + } + items.push(newMenuItem); + } + this.#contextMenu.replaceChildren(...items); + + this.#contextMenu.openPopupAtScreen( + event.screenX, + event.screenY, + true, + event + ); + }; + + /** + * Select the message in the appropriate folder for the history popup entry. + * Finds the message based on the value of the item, which is the relative + * index of the item in the message history. + * + * @param {Element} target + */ + #navigateToUri(target) { + const nsMsgViewIndex_None = 0xffffffff; + const historyIndex = Number.parseInt(target.getAttribute("value"), 10); + const tabmail = document.getElementById("tabmail"); + const currentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow; + const messageHistory = tabmail.currentAboutMessage.messageHistory; + if (!messageHistory || !messageHistory.canPop(historyIndex)) { + return; + } + const item = messageHistory.pop(historyIndex); + + if ( + currentWindow.displayFolder && + currentWindow.gFolder?.URI !== item.folderURI + ) { + const folder = MailServices.folderLookup.getFolderForURL(item.folderURI); + currentWindow.displayFolder(folder); + } + const msgHdr = MailServices.messageServiceFromURI( + item.messageURI + ).messageURIToMsgHdr(item.messageURI); + const index = currentWindow.gDBView.findIndexOfMsgHdr(msgHdr, true); + if (index != nsMsgViewIndex_None) { + if (currentWindow.threadTree) { + currentWindow.threadTree.selectedIndex = index; + currentWindow.threadTree.table.body.focus(); + } else { + currentWindow.gViewWrapper.dbView.selection.select(index); + currentWindow.displayMessage( + currentWindow.gViewWrapper.dbView.URIForFirstSelectedMessage + ); + } + } + } +} +customElements.define("mail-go-button", MailGoButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs b/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs new file mode 100644 index 0000000000..651502a934 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs @@ -0,0 +1,32 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +/** + * Unified toolbar button for toggling the quick filter bar. + */ +class QuickFilterBarToggle extends MailTabButton { + observed3PaneEvents = ["folderURIChanged", "select", "qfbtoggle"]; + observedAboutMessageEvents = []; + + onCommandContextChange() { + super.onCommandContextChange(); + const tabmail = document.getElementById("tabmail"); + const about3Pane = tabmail.currentAbout3Pane; + if ( + !about3Pane?.paneLayout || + about3Pane.paneLayout.accountCentralVisible + ) { + this.disabled = true; + this.setAttribute("aria-pressed", "false"); + return; + } + const active = about3Pane.quickFilterBar.filterer.visible; + this.setAttribute("aria-pressed", active.toString()); + } +} +customElements.define("quick-filter-bar-toggle", QuickFilterBarToggle, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs new file mode 100644 index 0000000000..e3ce55e05e --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs @@ -0,0 +1,15 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +/** + * Unified toolbar button for replying to a mailing list.. + */ +class ReplyListButton extends MailTabButton { + observedAboutMessageEvents = ["load", "MsgLoaded"]; +} +customElements.define("reply-list-button", ReplyListButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs new file mode 100644 index 0000000000..75c23592bf --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs @@ -0,0 +1,41 @@ +/* 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/. */ + +import { UnifiedToolbarButton } from "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs"; + +/* import-globals-from ../../../../base/content/spacesToolbar.js */ + +/** + * Unified toolbar button that opens a specific space. + * Attributes: + * - space: Space to open when the button is activated + */ +class SpaceButton extends UnifiedToolbarButton { + connectedCallback() { + super.connectedCallback(); + const spaceId = this.getAttribute("space"); + const space = gSpacesToolbar.spaces.find( + spaceDetails => spaceDetails.name == spaceId + ); + if (space.button.classList.contains("has-badge")) { + const badgeContainer = space.button.querySelector( + ".spaces-badge-container" + ); + this.badge = badgeContainer.textContent; + } + } + + handleClick = event => { + const spaceId = this.getAttribute("space"); + const space = gSpacesToolbar.spaces.find( + spaceDetails => spaceDetails.name == spaceId + ); + gSpacesToolbar.openSpace(document.getElementById("tabmail"), space); + event.preventDefault(); + event.stopPropagation(); + }; +} +customElements.define("space-button", SpaceButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs new file mode 100644 index 0000000000..3cd7686b5e --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs @@ -0,0 +1,40 @@ +/* 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/. */ + +import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs"; + +class ViewPickerButton extends MailTabButton { + observed3PaneEvents = ["folderURIChanged", "MailViewChanged"]; + + observedAboutMessageEvents = []; + + /** + * Update the label and icon of the button from the currently selected folder + * in the local 3pane. + */ + onCommandContextChange() { + const { gViewWrapper } = + document.getElementById("tabmail").currentAbout3Pane ?? {}; + if (!gViewWrapper) { + this.disabled = true; + return; + } + this.disabled = false; + const viewPickerPopup = document.getElementById(this.getAttribute("popup")); + const value = window.ViewPickerBinding.currentViewValue; + let selectedItem = viewPickerPopup.querySelector(`[value="${value}"]`); + if (!selectedItem) { + // We may have a new item, so refresh to make it show up. + window.RefreshAllViewPopups(viewPickerPopup, true); + selectedItem = viewPickerPopup.querySelector(`[value="${value}"]`); + } + this.label.textContent = selectedItem?.getAttribute("label"); + if (!this.label.textContent) { + document.l10n.setAttributes(this.label, "toolbar-view-picker-label"); + } + } +} +customElements.define("view-picker-button", ViewPickerButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs new file mode 100644 index 0000000000..afe84921dd --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs @@ -0,0 +1,549 @@ +/* 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/. */ + +import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import + +/** + * Shared implementation for a list box used as both a palette of items to add + * to a toolbar and a toolbar of items. + */ +export default class ListBoxSelection extends HTMLUListElement { + /** + * The currently selected item for keyboard operations. + * + * @type {?CustomizableElement} + */ + selectedItem = null; + + /** + * The item the context menu is opened for. + * + * @type {?CustomizableElement} + */ + contextMenuFor = null; + + /** + * Key name the primary action is executed on. + * + * @type {string} + */ + actionKey = "Enter"; + + /** + * The ID of the menu to show as context menu. + * + * @type {string} + */ + contextMenuId = ""; + + /** + * If items can be reordered in this list box. + * + * @type {boolean} + */ + canMoveItems = false; + + /** + * @returns {boolean} If the widget has connected previously. + */ + connectedCallback() { + if (this.hasConnected) { + return true; + } + this.hasConnected = true; + + this.setAttribute("role", "listbox"); + this.setAttribute("tabindex", "0"); + + this.addEventListener("contextmenu", this.handleContextMenu, { + capture: true, + }); + document + .getElementById(this.contextMenuId) + .addEventListener("popuphiding", this.#handleContextMenuClose); + this.addEventListener("keydown", this.#handleKey, { capture: true }); + this.addEventListener("click", this.#handleClick, { capture: true }); + this.addEventListener("focus", this.#handleFocus); + this.addEventListener("dragstart", this.#handleDragstart); + this.addEventListener("dragenter", this.#handleDragenter); + this.addEventListener("dragover", this.#handleDragover); + this.addEventListener("dragleave", this.#handleDragleave); + this.addEventListener("drop", this.#handleDrop); + this.addEventListener("dragend", this.#handleDragend); + return false; + } + + disconnectedCallback() { + this.contextMenuFor = null; + this.selectedItem = null; + } + + /** + * Default context menu event handler. Simply forwards the call to + * initializeContextMenu. + * + * @param {MouseEvent} event - The contextmenu mouse click event. + */ + handleContextMenu = event => { + this.initializeContextMenu(event); + }; + + /** + * Store the clicked item and open the context menu. + * + * @param {MouseEvent} event - The contextmenu mouse click event. + */ + initializeContextMenu(event) { + // If the context menu was opened by keyboard, we already have the item. + if (!this.contextMenuFor) { + this.contextMenuFor = event.target.closest("li"); + this.#clearSelection(); + } + document + .getElementById(this.contextMenuId) + .openPopupAtScreen(event.screenX, event.screenY, true); + } + + /** + * Discard the reference to the item the context menu is triggered on when the + * menu is closed. + */ + #handleContextMenuClose = () => { + this.contextMenuFor = null; + }; + + /** + * Make sure some element is selected when focus enters the element. + */ + #handleFocus = () => { + if (!this.selectedItem) { + this.selectItem(this.firstElementChild); + } + }; + + /** + * Handles basic list box keyboard interactions. + * + * @param {KeyboardEvent} event - The event for the key down. + */ + #handleKey = event => { + // Clicking into the list might clear the selection while retaining focus, + // so we need to make sure we have a selected item here. + if (!this.selectedItem) { + this.selectItem(this.firstElementChild); + } + const rightIsForward = document.dir === "ltr"; + switch (event.key) { + case this.actionKey: + this.primaryAction(this.selectedItem); + break; + case "Home": + if (this.canMoveItems && event.altKey) { + this.moveItemToStart(this.selectedItem); + break; + } + this.selectItem(this.firstElementChild); + break; + case "End": + if (this.canMoveItems && event.altKey) { + this.moveItemToEnd(this.selectedItem); + break; + } + this.selectItem(this.lastElementChild); + break; + case "ArrowLeft": + if (this.canMoveItems && event.altKey) { + if (rightIsForward) { + this.moveItemBackward(this.selectedItem); + break; + } + this.moveItemForward(this.selectedItem); + break; + } + if (rightIsForward) { + this.selectItem(this.selectedItem?.previousElementSibling); + break; + } + this.selectItem(this.selectedItem?.nextElementSibling); + break; + case "ArrowRight": + if (this.canMoveItems && event.altKey) { + if (rightIsForward) { + this.moveItemForward(this.selectedItem); + break; + } + this.moveItemBackward(this.selectedItem); + break; + } + if (rightIsForward) { + this.selectItem(this.selectedItem?.nextElementSibling); + break; + } + this.selectItem(this.selectedItem?.previousElementSibling); + break; + case "ContextMenu": + this.contextMenuFor = this.selectedItem; + return; + default: + return; + } + + event.stopPropagation(); + event.preventDefault(); + }; + + /** + * Handles the click event on an item in the list box. Marks the item as + * selected. + * + * @param {MouseEvent} event - The event for the mouse click. + */ + #handleClick = event => { + const item = event.target.closest("li"); + if (item) { + this.selectItem(item); + } else { + this.#clearSelection(); + } + event.stopPropagation(); + event.preventDefault(); + }; + + /** + * Set up the drag data transfer. + * + * @param {DragEvent} event - Drag start event. + */ + #handleDragstart = event => { + // Only allow dragging the customizable elements themeselves. + if (event.target.getAttribute("is") !== "customizable-element") { + event.preventDefault(); + return; + } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + "text/tb-item-id", + event.target.getAttribute("item-id") + ); + const customizableItem = event.target; + window.requestAnimationFrame(() => { + customizableItem.classList.add("dragging"); + }); + }; + + /** + * Calculate the drop position's closest sibling and the relative drop point. + * Assumes the list is laid out horizontally if canMoveItems is true. Else + * the sibling will be the event target and afterSibling will always be true. + * + * @param {DragEvent} event - The event the sibling being dragged over should + * be found in. + * @returns {{sibling: CustomizableElement, afterSibling: boolean}} + */ + #dragSiblingInfo(event) { + let sibling = event.target; + let afterSibling = true; + if (this.canMoveItems) { + const listBoundingRect = this.getBoundingClientRect(); + const listY = listBoundingRect.y + listBoundingRect.height / 2; + const element = this.getRootNode().elementFromPoint(event.x, listY); + sibling = element.closest('li[is="customizable-element"]'); + if (!sibling) { + if (!this.children.length) { + return {}; + } + sibling = this.lastElementChild; + } + const boundingRect = sibling.getBoundingClientRect(); + if (event.x < boundingRect.x + boundingRect.width / 2) { + afterSibling = false; + } + if (document.dir === "rtl") { + afterSibling = !afterSibling; + } + } + return { sibling, afterSibling }; + } + + /** + * Shared logic for when a drag event happens over a new part of the list. + * + * @param {DragEvent} event - Drag event. + */ + #dragIn(event) { + const itemId = event.dataTransfer.getData("text/tb-item-id"); + if (!itemId || !this.canAddElement(itemId)) { + event.dataTransfer.dropEffect = "none"; + event.preventDefault(); + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + if (!this.canMoveItems) { + return; + } + const { sibling, afterSibling } = this.#dragSiblingInfo(event); + if (!sibling) { + return; + } + sibling.classList.toggle("drop-before", !afterSibling); + sibling.classList.toggle("drop-after", afterSibling); + sibling.nextElementSibling?.classList.remove("drop-before", "drop-after"); + sibling.previousElementSibling?.classList.remove( + "drop-before", + "drop-after" + ); + } + + /** + * Shared logic for when a drag leaves an element. + * + * @param {Element} element - Element the drag has left. + */ + #dragOut(element) { + element.classList.remove("drop-after", "drop-before"); + if (element !== this) { + return; + } + for (const child of this.querySelectorAll(".drop-after,.drop-before")) { + child.classList.remove("drop-after", "drop-before"); + } + } + + /** + * Prevents the default action for the dragenter event to enable dropping + * items on this list. Shows a drag position placeholder in the target if + * applicable. + * + * @param {DragEvent} event - Drag enter event. + */ + #handleDragenter = event => { + this.#dragIn(event); + }; + + /** + * Prevents the default for the dragover event to enable dropping items on + * this list. Shows a drag position placeholder in the target if applicable. + * + * @param {DragEvent} event - Drag over event. + */ + #handleDragover = event => { + this.#dragIn(event); + }; + + /** + * Hide the drag position placeholder. + * + * @param {DragEvent} event - Drag leave event. + */ + #handleDragleave = event => { + if (!this.canMoveItems) { + return; + } + this.#dragOut(event.target); + }; + + /** + * Move the item to the dragged into given position. Possibly moving adopting + * it from another list. + * + * @param {DragEvent} event - Drop event. + */ + #handleDrop = event => { + const itemId = event.dataTransfer.getData("text/tb-item-id"); + if ( + event.dataTransfer.dropEffect !== "move" || + !itemId || + !this.canAddElement(itemId) + ) { + return; + } + + const { sibling, afterSibling } = this.#dragSiblingInfo(event); + + event.preventDefault(); + this.#dragOut(sibling ?? this); + this.handleDrop(itemId, sibling, afterSibling); + }; + + /** + * Remove the item from this list if it was dropped into another list. Return + * it to its palette if dropped outside a valid target. + * + * @param {DragEvent} event - Drag end event. + */ + #handleDragend = event => { + event.target.classList.remove("dragging"); + if (event.dataTransfer.dropEffect === "move") { + this.handleDragSuccess(event.target); + return; + } + // If we can't move the item to the drop location, return it to its palette. + const palette = event.target.palette; + if (event.dataTransfer.dropEffect === "none" && palette !== this) { + event.preventDefault(); + this.handleDragSuccess(event.target); + palette.returnItem(event.target); + } + }; + + /** + * Handle an item from a drag operation being added to the list. The drag + * origin could be this list or another list. + * + * @param {string} itemId - Item ID to add to this list from a drop. + * @param {CustomizableElement} sibling - Sibling this item should end up next + * to. + * @param {boolean} afterSibling - If the item should be inserted after the + * sibling. + * @return {CustomizableElement} The dropped customizable element created by + * this handler. + */ + handleDrop(itemId, sibling, afterSibling) { + const item = document.createElement("li", { + is: "customizable-element", + }); + item.setAttribute("item-id", itemId); + item.draggable = true; + if (!this.canMoveItems || !sibling) { + this.appendChild(item); + return item; + } + if (afterSibling) { + sibling.after(item); + return item; + } + sibling.before(item); + return item; + } + + /** + * Handle an item from this list having been dragged somewhere else. + * + * @param {CustomizableElement} item - Item dragged somewhere else. + */ + handleDragSuccess(item) { + item.remove(); + } + + /** + * Check if a given item is allowed to be added to this list. Is false if the + * item is already in the list and moving around is not allowed. + * + * @param {string} itemId - The item ID of the item that wants to be added to + * this list. + * @returns {boolean} If this item can be added to this list. + */ + canAddElement(itemId) { + return this.canMoveItems || !this.querySelector(`li[item-id="${itemId}"]`); + } + + /** + * Move the item forward in the list box. Only works if canMoveItems is true. + * + * @param {CustomizableElement} item - The item to move forward. + */ + moveItemForward(item) { + if (!this.canMoveItems) { + return; + } + item.nextElementSibling?.after(item); + } + + /** + * Move the item backward in the list box. Only works if canMoveItems is true. + * + * @param {CustomizableElement} item - The item to move backward. + */ + moveItemBackward(item) { + if (!this.canMoveItems) { + return; + } + item.previousElementSibling?.before(item); + } + + /** + * Move the item to the start of the list. Only works if canMoveItems is + * true. + * + * @param {CustomizableElement} item - The item to move to the start. + */ + moveItemToStart(item) { + if (!this.canMoveItems || item === this.firstElementChild) { + return; + } + this.prepend(item); + } + + /** + * Move the item to the end of the list. Only works if canMoveItems is true. + * + * @param {CustomizableElement} item - The item to move to the end. + */ + moveItemToEnd(item) { + if (!this.canMoveItems || item === this.lastElementChild) { + return; + } + this.appendChild(item); + } + + /** + * Select the item. Removes the selection of the previous item. No-op if no + * item is passed. + * + * @param {CustomizableElement} item - The item to select. + */ + selectItem(item) { + if (item) { + this.selectedItem?.removeAttribute("aria-selected"); + item.setAttribute("aria-selected", "true"); + this.selectedItem = item; + this.setAttribute("aria-activedescendant", item.id); + } + } + + /** + * Clear the selection inside the list box. + */ + #clearSelection() { + this.selectedItem?.removeAttribute("aria-selected"); + this.selectedItem = null; + this.removeAttribute("aria-activedescendant"); + } + + /** + * Select the next item in the list. If there are no more items in either + * direction, the selection state is reset. + * + * @param {CustomizableElement} item - The item of which the next sibling + * should be the new selection. + */ + #selectNextItem(item) { + const nextItem = item.nextElementSibling || item.previousElementSibling; + if (nextItem) { + this.selectItem(nextItem); + return; + } + this.#clearSelection(); + } + + /** + * Execute the primary action on the item after it has been deselected and the + * next item was selected. Implementations are expected to override this + * method and call it as the first step, aborting if it returns true. + * + * @param {CustomizableElement} item - The item the primary action should be + * executed on. + * @returns {boolean} If the action should be aborted. + */ + primaryAction(item) { + if (!item) { + return true; + } + item.removeAttribute("aria-selected"); + this.#selectNextItem(item); + return false; + } +} diff --git a/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs b/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs new file mode 100644 index 0000000000..a0eeee2279 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs @@ -0,0 +1,153 @@ +/* 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/. */ + +import { UnifiedToolbarButton } from "./unified-toolbar-button.mjs"; + +/* import-globals-from ../../../base/content/globalOverlay.js */ + +/** + * Mail tab specific unified toolbar button. Instead of tracking a global + * command, its state gets re-evaluated every time the state of about:3pane or + * about:message tab changes in a relevant way. + */ +export class MailTabButton extends UnifiedToolbarButton { + /** + * Array of events to listen for on the about:3pane document. + * + * @type {string[]} + */ + observed3PaneEvents = ["folderURIChanged", "select"]; + + /** + * Array of events to listen for on the message browser. + * + * @type {string[]} + */ + observedAboutMessageEvents = ["load"]; + + /** + * Listeners we've added in tabs. + * + * @type {{tabId: any, target: EventTarget, event: string, callback: function}[]} + */ + #listeners = []; + + connectedCallback() { + super.connectedCallback(); + this.#addTabListeners(); + this.onCommandContextChange(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + for (const listener of this.#listeners) { + listener.target.removeEventListener(listener.event, listener.callback); + } + this.#listeners.length = 0; + } + + /** + * Callback for customizable-element when the current tab is switched while + * this button is visible. + */ + onTabSwitched() { + this.#addTabListeners(); + this.onCommandContextChange(); + } + + /** + * Callback for customizable-element when a tab is closed. + * + * @param {TabInfo} tab + */ + onTabClosing(tab) { + this.#removeListenersForTab(tab.tabId); + } + + /** + * Remove all event listeners this button has for a given tab. + * + * @param {*} tabId - ID of the tab to remove listeners for. + */ + #removeListenersForTab(tabId) { + for (const listener of this.#listeners) { + if (listener.tabId === tabId) { + listener.target.removeEventListener(listener.event, listener.callback); + } + } + this.#listeners = this.#listeners.filter( + listener => listener.tabId !== tabId + ); + } + + /** + * Add missing event listeners for the current tab. + */ + #addTabListeners() { + const tabmail = document.getElementById("tabmail"); + const tabId = tabmail.currentTabInfo.tabId; + const existingListeners = this.#listeners.filter( + listener => listener.tabId === tabId + ); + let expectedEventListeners = []; + switch (tabmail.currentTabInfo.mode.name) { + case "mail3PaneTab": + expectedEventListeners = this.observed3PaneEvents.concat( + this.observedAboutMessageEvents + ); + break; + case "mailMessageTab": + expectedEventListeners = this.observedAboutMessageEvents.concat(); + break; + } + const missingListeners = expectedEventListeners.filter(event => + existingListeners.every(listener => listener.event !== event) + ); + if (!missingListeners.length) { + return; + } + const contentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow; + for (const event of missingListeners) { + const listener = { + event, + tabId, + callback: this.#handle3PaneChange, + target: contentWindow, + }; + if ( + this.observedAboutMessageEvents.includes(event) && + contentWindow.messageBrowser + ) { + listener.target = contentWindow.messageBrowser.contentWindow; + } + listener.target.addEventListener(listener.event, listener.callback); + this.#listeners.push(listener); + } + } + + /** + * Event handling callback when an event by a tab is fired. + */ + #handle3PaneChange = () => { + this.onCommandContextChange(); + }; + + /** + * Handle the context changing, updating the disabled state for the button + * etc. + */ + onCommandContextChange() { + if (!this.observedCommand) { + return; + } + try { + this.disabled = !getEnabledControllerForCommand(this.observedCommand); + } catch { + this.disabled = true; + } + } +} +customElements.define("mail-tab-button", MailTabButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/search-bar.mjs b/comm/mail/components/unifiedtoolbar/content/search-bar.mjs new file mode 100644 index 0000000000..a450f7349f --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/search-bar.mjs @@ -0,0 +1,121 @@ +/* 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/. */ + +/** + * Search input with customizable search button and placeholder. + * Attributes: + * - label: Search field label for accessibility tree. + * - disabled: When present, disable the search field and button. + * Slots in template (#searchBarTemplate): + * - placeholder: Content displayed as placeholder. When not provided, the value + * of the label attribute is shown as placeholder. + * - button: Content displayed on the search button. + * + * @emits search: Event when a search should be executed. detail holds the + * search term. + * @emits autocomplte: Auto complete update. detail holds the current search + * term. + */ +export class SearchBar extends HTMLElement { + static get observedAttributes() { + return ["label", "disabled"]; + } + + /** + * Reference to the input field in the form. + * + * @type {?HTMLInputElement} + */ + #input = null; + + /** + * Reference to the search button in the form. + * + * @type {?HTMLButtonElement} + */ + #button = null; + + #onSubmit = event => { + event.preventDefault(); + if (!this.#input.value) { + return; + } + + const searchEvent = new CustomEvent("search", { + detail: this.#input.value, + cancelable: true, + }); + if (this.dispatchEvent(searchEvent)) { + this.reset(); + } + }; + + #onInput = () => { + const autocompleteEvent = new CustomEvent("autocomplete", { + detail: this.#input.value, + }); + this.dispatchEvent(autocompleteEvent); + }; + + connectedCallback() { + if (this.shadowRoot) { + return; + } + + const shadowRoot = this.attachShadow({ mode: "open" }); + + const template = document + .getElementById("searchBarTemplate") + .content.cloneNode(true); + this.#input = template.querySelector("input"); + this.#button = template.querySelector("button"); + + template.querySelector("form").addEventListener("submit", this.#onSubmit, { + passive: false, + }); + + this.#input.setAttribute("aria-label", this.getAttribute("label")); + template.querySelector("slot[name=placeholder]").textContent = + this.getAttribute("label"); + this.#input.addEventListener("input", this.#onInput); + + const styles = document.createElement("link"); + styles.setAttribute("rel", "stylesheet"); + styles.setAttribute( + "href", + "chrome://messenger/skin/shared/search-bar.css" + ); + shadowRoot.append(styles, template); + } + + attributeChangedCallback(attributeName, oldValue, newValue) { + if (!this.#input) { + return; + } + switch (attributeName) { + case "label": + this.#input.setAttribute("aria-label", newValue); + this.shadowRoot.querySelector("slot[name=placeholder]").textContent = + newValue; + break; + case "disabled": { + const isDisabled = this.hasAttribute("disabled"); + this.#input.disabled = isDisabled; + this.#button.disabled = isDisabled; + } + } + } + + focus() { + this.#input.focus(); + } + + /** + * Reset the search bar to its empty state. + */ + reset() { + this.#input.value = ""; + } +} +customElements.define("search-bar", SearchBar); diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs new file mode 100644 index 0000000000..466a83f0c1 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs @@ -0,0 +1,240 @@ +/* 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/. */ + +//TODO keyboard handling, keyboard + commands + +/* import-globals-from ../../../base/content/globalOverlay.js */ + +/** + * Toolbar button implementation for the unified toolbar. + * Template ID: unifiedToolbarButtonTemplate + * Attributes: + * - command: ID string of the command to execute when the button is pressed. + * - observes: ID of command to observe for disabled state. Defaults to value of + * command attribute. + * - popup: ID of the popup to open when the button is pressed. The popup is + * anchored to the button. Overrides any other click handling. + * - disabled: When set the button is disabled. + * - title: Tooltip to show on the button. + * - label: Label text of the button. Observed for changes. + * - label-id: A fluent ID for the label instead of the label attribute. + * Observed for changes. + * - badge: When set, the value of the attribute is shown as badge. + * - aria-pressed: set to "false" to make the button behave like a toggle. + * Events: + * - buttondisabled: Fired when the button gets disabled while it is keyboard + * navigable. + * - buttonenabled: Fired when the button gets enabled again but isn't marked to + * be keyboard navigable. + */ +export class UnifiedToolbarButton extends HTMLButtonElement { + static get observedAttributes() { + return ["label", "label-id", "disabled"]; + } + + /** + * Container for the button label. + * + * @type {?HTMLSpanElement} + */ + label = null; + + /** + * Name of the command this button follows the disabled (and if it is a toggle + * button the checked) state of. + * + * @type {string?} + */ + observedCommand; + + /** + * The mutation observer observing the command this button follows the state + * of. + * + * @type {MutationObserver?} + */ + #observer = null; + + connectedCallback() { + // We remove the mutation overserver when the element is disconnected, thus + // we have to add it every time the element is connected. + this.observedCommand = + this.getAttribute("observes") || this.getAttribute("command"); + if (this.observedCommand) { + const command = document.getElementById(this.observedCommand); + if (command) { + if (!this.#observer) { + this.#observer = new MutationObserver(this.#handleCommandMutation); + } + const observedAttributes = ["disabled"]; + if (this.hasAttribute("aria-pressed")) { + observedAttributes.push("checked"); + + // Update the pressed state from the command + this.setAttribute( + "aria-pressed", + command.getAttribute("checked") ?? "false" + ); + } + this.#observer.observe(command, { + attributes: true, + attributeFilter: observedAttributes, + }); + } + // Update the disabled state to match the current state of the command. + try { + this.disabled = !getEnabledControllerForCommand(this.observedCommand); + } catch { + this.disabled = true; + } + } + if (this.hasConnected) { + return; + } + this.hasConnected = true; + this.classList.add("unified-toolbar-button", "button"); + + const template = document + .getElementById("unifiedToolbarButtonTemplate") + .content.cloneNode(true); + this.label = template.querySelector("span"); + this.#updateLabel(); + this.appendChild(template); + this.addEventListener("click", event => this.handleClick(event)); + } + + disconnectedCallback() { + if (this.#observer) { + this.#observer.disconnect(); + } + } + + attributeChangedCallback(attribute) { + switch (attribute) { + case "label": + case "label-id": + this.#updateLabel(); + break; + case "disabled": + if (!this.hasConnected) { + return; + } + if (this.disabled && this.tabIndex !== -1) { + this.tabIndex = -1; + this.dispatchEvent(new CustomEvent("buttondisabled")); + } else if (!this.disabled && this.tabIndex === -1) { + this.dispatchEvent(new CustomEvent("buttonenabled")); + } + break; + } + } + + /** + * Default handling for clicks on the button. Shows the associated popup, + * executes the given command and toggles the button state. + * + * @param {MouseEvent} event - Click event. + */ + handleClick(event) { + if (this.hasAttribute("popup")) { + event.preventDefault(); + event.stopPropagation(); + const popup = document.getElementById(this.getAttribute("popup")); + popup.openPopup(this, { + position: "after_start", + triggerEvent: event, + }); + this.setAttribute("aria-pressed", "true"); + const hideListener = () => { + if (popup.state === "open") { + return; + } + this.removeAttribute("aria-pressed"); + popup.removeEventListener("popuphiding", hideListener); + }; + popup.addEventListener("popuphiding", hideListener); + return; + } + if (this.hasAttribute("aria-pressed")) { + const isPressed = this.getAttribute("aria-pressed") === "true"; + this.setAttribute("aria-pressed", (!isPressed).toString()); + } + if (this.hasAttribute("command")) { + const command = this.getAttribute("command"); + let controller = getEnabledControllerForCommand(command); + if (controller) { + event.preventDefault(); + event.stopPropagation(); + controller = controller.wrappedJSObject ?? controller; + controller.doCommand(command, event); + return; + } + const commandElement = document.getElementById(command); + if (!commandElement) { + return; + } + event.preventDefault(); + event.stopPropagation(); + commandElement.doCommand(); + } + } + + /** + * Callback for the mutation observer on the command this button follows. + * + * @param {Mutation[]} mutationList - List of mutations the observer saw. + */ + #handleCommandMutation = mutationList => { + for (const mutation of mutationList) { + if (mutation.type !== "attributes") { + continue; + } + if (mutation.attributeName === "disabled") { + this.disabled = mutation.target.getAttribute("disabled") === "true"; + } else if (mutation.attributeName === "checked") { + this.setAttribute( + "aria-pressed", + mutation.target.getAttribute("checked") + ); + } + } + }; + + /** + * Update the contents of the label from the attributes of this element. + */ + #updateLabel() { + if (!this.label) { + return; + } + if (this.hasAttribute("label")) { + this.label.textContent = this.getAttribute("label"); + return; + } + if (this.hasAttribute("label-id")) { + document.l10n.setAttributes(this.label, this.getAttribute("label-id")); + } + } + + /** + * Badge displayed on the button. To clear the badge, set to empty string or + * nullish value. + * + * @type {string} + */ + set badge(badgeText) { + if (badgeText === "" || badgeText == null) { + this.removeAttribute("badge"); + return; + } + this.setAttribute("badge", badgeText); + } + + get badge() { + return this.getAttribute("badge"); + } +} +customElements.define("unified-toolbar-button", UnifiedToolbarButton, { + extends: "button", +}); diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs new file mode 100644 index 0000000000..a43b7c6005 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs @@ -0,0 +1,264 @@ +/* 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/. */ + +import "./search-bar.mjs"; // eslint-disable-line import/no-unassigned-import +import "./customization-palette.mjs"; // eslint-disable-line import/no-unassigned-import +import "./customization-target.mjs"; // eslint-disable-line import/no-unassigned-import +import { + BUTTON_STYLE_MAP, + BUTTON_STYLE_PREF, +} from "resource:///modules/ButtonStyle.mjs"; + +const { getDefaultItemIdsForSpace } = ChromeUtils.importESModule( + "resource:///modules/CustomizableItems.sys.mjs" +); + +/** + * Template ID: unifiedToolbarCustomizationPaneTemplate + * Attributes: + * - space: Identifier of the space this pane is for. Changes are not observed. + * - current-items: Currently used items in this space. + * - builtin-space: Boolean indicating if the space is a built in space (true) or an + * extension provided space (false). + */ +class UnifiedToolbarCustomizationPane extends HTMLElement { + /** + * Reference to the customization target for the main toolbar area. + * + * @type {CustomizationTarget?} + */ + #toolbarTarget = null; + + /** + * Reference to the title of the space specific palette. + * + * @type {?HTMLHeadingElement} + */ + #spaceSpecificTitle = null; + + /** + * Reference to the palette for items only available in the current space. + * + * @type {?CustomizationPalette} + */ + #spaceSpecificPalette = null; + + /** + * Reference to the palette for items available in all spaces. + * + * @type {?CustomizationPalette} + */ + #genericPalette = null; + + /** + * List of the item IDs that are in the toolbar by default in this area. + * + * @type {string[]} + */ + #defaultItemIds = []; + + /** + * The search bar used to filter the items in the palettes. + * + * @type {?SearchBar} + */ + #searchBar = null; + + connectedCallback() { + if (this.shadowRoot) { + document.l10n.connectRoot(this.shadowRoot); + return; + } + this.setAttribute("role", "tabpanel"); + const shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + + const space = this.getAttribute("space"); + + const template = document + .getElementById("unifiedToolbarCustomizationPaneTemplate") + .content.cloneNode(true); + const styles = document.createElement("link"); + styles.setAttribute("rel", "stylesheet"); + styles.setAttribute( + "href", + "chrome://messenger/skin/shared/unifiedToolbarCustomizationPane.css" + ); + + this.#toolbarTarget = template.querySelector(".toolbar-target"); + this.#toolbarTarget.setAttribute("space", space); + + this.#spaceSpecificTitle = template.querySelector(".space-specific-title"); + document.l10n.setAttributes( + this.#spaceSpecificTitle, + this.hasAttribute("builtin-space") + ? `customize-palette-${space}-specific-title` + : "customize-palette-extension-specific-title" + ); + this.#spaceSpecificTitle.id = `${space}PaletteTitle`; + this.#spaceSpecificPalette = template.querySelector( + ".space-specific-palette" + ); + this.#spaceSpecificPalette.id = `${space}Palette`; + this.#spaceSpecificPalette.setAttribute( + "aria-labelledby", + this.#spaceSpecificTitle.id + ); + this.#spaceSpecificPalette.setAttribute("space", space); + const genericTitle = template.querySelector(".generic-palette-title"); + genericTitle.id = `${space}GenericPaletteTitle`; + this.#genericPalette = template.querySelector(".generic-palette"); + this.#genericPalette.id = `${space}GenericPalette`; + this.#genericPalette.setAttribute("aria-labelledby", genericTitle.id); + + this.#searchBar = template.querySelector("search-bar"); + this.#searchBar.addEventListener("search", this.#handleSearch); + this.#searchBar.addEventListener("autocomplete", this.#handleFilter); + + this.initialize(); + + shadowRoot.append(styles, template); + + this.addEventListener("dragover", this.#handleDragover); + } + + disconnectedCallback() { + document.l10n.disconnectRoot(this.shadowRoot); + } + + #handleFilter = event => { + this.#spaceSpecificPalette.filterItems(event.detail); + this.#genericPalette.filterItems(event.detail); + }; + + #handleSearch = event => { + // Don't clear the search bar. + event.preventDefault(); + }; + + /** + * Default handler to indicate nothing can be dropped in the customization, + * except for the dragging and dropping in the palettes and targets. + * + * @param {DragEvent} event - Drag over event. + */ + #handleDragover = event => { + event.dataTransfer.dropEffect = "none"; + event.preventDefault(); + }; + + /** + * Initialize the contents of this element from the state. The relevant state + * for this element are the items currently in the toolbar for this space. + * + * @param {boolean} [deep = false] - If true calls initialize on all the + * targets and palettes. + */ + initialize(deep = false) { + const space = this.getAttribute("space"); + this.#defaultItemIds = getDefaultItemIdsForSpace(space); + const currentItems = this.hasAttribute("current-items") + ? this.getAttribute("current-items") + : this.#defaultItemIds.join(","); + this.#toolbarTarget.setAttribute("current-items", currentItems); + this.#spaceSpecificPalette.setAttribute("items-in-use", currentItems); + this.#genericPalette.setAttribute("items-in-use", currentItems); + + if (deep) { + this.#searchBar.reset(); + this.#toolbarTarget.initialize(); + this.#spaceSpecificPalette.initialize(); + this.#genericPalette.initialize(); + this.#spaceSpecificTitle.hidden = this.#spaceSpecificPalette.isEmpty; + this.#spaceSpecificPalette.hidden = this.#spaceSpecificPalette.isEmpty; + } + + this.updateButtonStyle( + BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)] + ); + } + + /** + * Reset the items in the targets to the defaults. + */ + reset() { + this.#toolbarTarget.setItems(this.#defaultItemIds); + this.#spaceSpecificPalette.setItems(this.#defaultItemIds); + this.#genericPalette.setItems(this.#defaultItemIds); + } + + /** + * Add an item to the default target in this space. Can only add items that + * are available in all spaces. + * + * @param {string} itemId - Item ID of the item to add to the default target. + */ + addItem(itemId) { + this.#genericPalette.addItemById(itemId); + } + + /** + * Remove an item from all targets in this space. + * + * @param {string} itemId - Item ID of the item to remove from this pane's + * targets. + */ + removeItem(itemId) { + this.#toolbarTarget.removeItemById(itemId); + } + + /** + * Check if an item is currently in a target in this pane. + * + * @param {string} itemId - Item ID of the item to check for. + * @returns {boolean} If the item is currently used in this pane. + */ + hasItem(itemId) { + return Boolean(this.#toolbarTarget.hasItem(itemId)); + } + + /** + * If the customization state of this space matches its default state. + * + * @type {boolean} + */ + get matchesDefaultState() { + const itemsInToolbar = this.#toolbarTarget.itemIds; + return itemsInToolbar.join(",") === this.#defaultItemIds.join(","); + } + + /** + * If the customization state of this space matches the currently saved + * configuration. + * + * @type {boolean} + */ + get hasChanges() { + return this.#toolbarTarget.hasChanges; + } + + /** + * Current customization state for this space. + * + * @type {string[]} + */ + get itemIds() { + return this.#toolbarTarget.itemIds; + } + + /** + * Update the class of the toolbar preview to reflect the selected button + * style. + * + * @param {string} value - The class to apply. + */ + updateButtonStyle(value) { + this.#toolbarTarget.classList.remove(...BUTTON_STYLE_MAP); + this.#toolbarTarget.classList.add(value); + } +} +customElements.define( + "unified-toolbar-customization-pane", + UnifiedToolbarCustomizationPane +); diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs new file mode 100644 index 0000000000..1acdf85b57 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs @@ -0,0 +1,414 @@ +/* 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/. */ + +/* import-globals-from ../../../base/content/spacesToolbar.js */ +/* import-globals-from ../../../base/content/utilityOverlay.js */ + +import { + storeState, + getState, +} from "resource:///modules/CustomizationState.mjs"; +import "./unified-toolbar-tab.mjs"; // eslint-disable-line import/no-unassigned-import +import "./unified-toolbar-customization-pane.mjs"; // eslint-disable-line import/no-unassigned-import +import { + BUTTON_STYLE_MAP, + BUTTON_STYLE_PREF, +} from "resource:///modules/ButtonStyle.mjs"; + +/** + * Set of names of the built in spaces. + * + * @type {Set<string>} + */ +const BUILTIN_SPACES = new Set([ + "mail", + "addressbook", + "calendar", + "tasks", + "chat", + "settings", +]); + +/** + * Customization palette container for the unified toolbar. Contained in a + * custom element for state management. When visible, the document should have + * the customizingUnifiedToolbar class. + * Template: #unifiedToolbarCustomizationTemplate. + */ +class UnifiedToolbarCustomization extends HTMLElement { + /** + * Reference to the container where the space tabs go in. The tab panels will + * be placed after this element. + * + * @type {?HTMLDivElement} + */ + #tabList = null; + + #buttonStyle = null; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + const template = document + .getElementById("unifiedToolbarCustomizationTemplate") + .content.cloneNode(true); + const form = template.querySelector("form"); + form.addEventListener( + "submit", + event => { + event.preventDefault(); + this.#save(); + }, + { + passive: false, + } + ); + form.addEventListener("reset", event => { + this.#reset(); + }); + template + .querySelector("#unifiedToolbarCustomizationCancel") + .addEventListener("click", () => { + this.toggle(false); + }); + this.#buttonStyle = template.querySelector("#buttonStyle"); + this.#buttonStyle.addEventListener("change", this.#handleButtonStyleChange); + this.addEventListener("itemchange", this.#handleItemChange, { + capture: true, + }); + this.addEventListener("additem", this.#handleAddItem, { + capture: true, + }); + this.addEventListener("removeitem", this.#handleRemoveItem, { + capture: true, + }); + this.#tabList = template.querySelector("#customizationTabs"); + this.#tabList.addEventListener("tabswitch", this.#handleTabSwitch, { + capture: true, + }); + template + .querySelector("#customizationToSettingsButton") + .addEventListener("click", this.#handleSettingsButton); + this.initialize(); + this.append(template); + this.#updateResetToDefault(); + this.addEventListener("keyup", this.#handleKeyboard); + this.addEventListener("keyup", this.#closeByKeyboard); + this.addEventListener("keypress", this.#handleKeyboard); + this.addEventListener("keydown", this.#handleKeyboard); + } + + #handleItemChange = event => { + event.stopPropagation(); + this.#updateResetToDefault(); + this.#updateUnsavedChangesState(); + }; + + #handleTabSwitch = event => { + event.stopPropagation(); + this.#updateUnsavedChangesState(); + }; + + #handleButtonStyleChange = event => { + for (const pane of this.querySelectorAll( + "unified-toolbar-customization-pane" + )) { + pane.updateButtonStyle(event.target.value); + } + this.#updateUnsavedChangesState(); + }; + + #handleSettingsButton = event => { + event.preventDefault(); + openPreferencesTab("paneGeneral", "layoutGroup"); + this.toggle(false); + }; + + #handleAddItem = event => { + event.stopPropagation(); + const tabPanes = Array.from( + this.querySelectorAll("unified-toolbar-customization-pane") + ); + for (const pane of tabPanes) { + pane.addItem(event.detail.itemId); + } + }; + + #handleRemoveItem = event => { + event.stopPropagation(); + const tabPanes = Array.from( + this.querySelectorAll("unified-toolbar-customization-pane") + ); + for (const pane of tabPanes) { + pane.removeItem(event.detail.itemId); + } + }; + + /** + * Close the customisation pane when Escape is released + * + * @param {KeyboardEvent} event - The keyboard event + */ + #closeByKeyboard = event => { + if (event.key == "Escape") { + event.preventDefault(); + this.toggle(false); + } + }; + + /** + * Ensure keyboard events are not propagated outside the customization dialog. + * + * @param {KeyboardEvent} event - The keyboard event. + */ + #handleKeyboard = event => { + event.stopPropagation(); + }; + + /** + * Update state of reset to default button. + */ + #updateResetToDefault() { + const tabPanes = Array.from( + this.querySelectorAll("unified-toolbar-customization-pane") + ); + const isDefault = tabPanes.every(pane => pane.matchesDefaultState); + this.querySelector('button[type="reset"]').disabled = isDefault; + } + + #updateUnsavedChangesState() { + const tabPanes = Array.from( + this.querySelectorAll("unified-toolbar-customization-pane") + ); + const unsavedChanges = + tabPanes.some(tabPane => tabPane.hasChanges) || + this.#buttonStyle.value != + BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)]; + const otherSpacesHaveUnsavedChanges = + unsavedChanges && + tabPanes.some(tabPane => tabPane.hidden && tabPane.hasChanges); + this.querySelector('button[type="submit"]').disabled = !unsavedChanges; + document.getElementById( + "unifiedToolbarCustomizationUnsavedChanges" + ).hidden = !otherSpacesHaveUnsavedChanges; + } + + /** + * Generate a tab and tab pane that are linked together for the given space. + * If the space is the current space, the tab is marked as active. + * + * @param {SpaceInfo} space + * @returns {{tab: UnifiedToolbarTab, tabPane: UnifiedToolbarCustomizationPane}} + */ + #makeSpaceTab(space) { + const activeSpace = space === gSpacesToolbar.currentSpace; + const tabId = `unified-toolbar-customization-tab-${space.name}`; + const paneId = `unified-toolbar-customization-pane-${space.name}`; + const tab = document.createElement("unified-toolbar-tab"); + tab.id = tabId; + tab.setAttribute("aria-controls", paneId); + if (activeSpace) { + tab.setAttribute("selected", true); + } + const isBuiltinSpace = BUILTIN_SPACES.has(space.name); + if (isBuiltinSpace) { + document.l10n.setAttributes(tab, `customize-space-tab-${space.name}`); + } else { + const title = space.button.title; + tab.textContent = title; + tab.title = title; + tab.style = space.button.querySelector("img").style.cssText; + } + const tabPane = document.createElement( + "unified-toolbar-customization-pane" + ); + tabPane.id = paneId; + tabPane.setAttribute("space", space.name); + tabPane.setAttribute("aria-labelledby", tabId); + tabPane.toggleAttribute("builtin-space", isBuiltinSpace); + tabPane.hidden = !activeSpace; + return { tab, tabPane }; + } + + /** + * Reset all the spaces to their default customization state. + */ + #reset() { + const tabPanes = Array.from( + this.querySelectorAll("unified-toolbar-customization-pane") + ); + for (const pane of tabPanes) { + pane.reset(); + } + } + + /** + * Save the current state of the toolbar and hide the customization. + */ + #save() { + const tabPanes = Array.from( + this.querySelectorAll("unified-toolbar-customization-pane") + ); + const state = Object.fromEntries( + tabPanes + .filter(pane => !pane.matchesDefaultState) + .map(pane => [pane.getAttribute("space"), pane.itemIds]) + ); + Services.prefs.setIntPref( + BUTTON_STYLE_PREF, + BUTTON_STYLE_MAP.indexOf(this.#buttonStyle.value) + ); + // Toggle happens before saving, so the newly restored buttons don't have to + // be updated when the globalOverlay flag on tabmail goes away. + this.toggle(false); + storeState(state); + } + + /** + * Initialize the contents of this from the current state. Specifically makes + * sure all the spaces have a tab, and all tabs still have a space. + * + * @param {boolean} [deep = false] - If true calls initialize on all tab + * panes. + */ + initialize(deep = false) { + const state = getState(); + const existingTabs = Array.from(this.#tabList.children); + const tabSpaces = existingTabs.map(tab => tab.id.split("-").pop()); + const spaceNames = new Set(gSpacesToolbar.spaces.map(space => space.name)); + const removedTabs = existingTabs.filter( + (tab, index) => !spaceNames.has(tabSpaces[index]) + ); + for (const tab of removedTabs) { + tab.pane.remove(); + tab.remove(); + } + const newTabs = gSpacesToolbar.spaces.map(space => { + if (tabSpaces.includes(space.name)) { + const tab = existingTabs[tabSpaces.indexOf(space.name)]; + if (!BUILTIN_SPACES.has(space.name)) { + const title = space.button.title; + tab.textContent = title; + tab.title = title; + tab.style = space.button.querySelector("img").style.cssText; + } + return [tab, tab.pane]; + } + const { tab, tabPane } = this.#makeSpaceTab(space); + return [tab, tabPane]; + }); + this.#tabList.replaceChildren(...newTabs.map(([tab]) => tab)); + let previousNode = this.#tabList; + for (const [, tabPane] of newTabs) { + previousNode.after(tabPane); + const space = tabPane.getAttribute("space"); + if (state.hasOwnProperty(space)) { + tabPane.setAttribute("current-items", state[space].join(",")); + } else { + tabPane.removeAttribute("current-items"); + } + previousNode = tabPane; + if (deep) { + tabPane.initialize(deep); + } + } + this.#buttonStyle.value = + BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)]; + // Update state of reset to default button only when updating tab panes too. + if (deep) { + this.#updateResetToDefault(); + this.#updateUnsavedChangesState(); + } + } + + /** + * Toggle unified toolbar customization. + * + * @param {boolean} [visible] - If passed, defines if customization should + * be active. + */ + toggle(visible) { + if (visible) { + this.initialize(true); + let tabToSelect; + if (gSpacesToolbar.currentSpace) { + tabToSelect = document.getElementById( + `unified-toolbar-customization-tab-${gSpacesToolbar.currentSpace.name}` + ); + } + if ( + !tabToSelect && + !this.querySelector(`unified-toolbar-tab[selected="true"]`) + ) { + tabToSelect = this.querySelector("unified-toolbar-tab"); + } + if (tabToSelect) { + tabToSelect.select(); + } + } + + document.getElementById("tabmail").globalOverlay = visible; + document.documentElement.classList.toggle( + "customizingUnifiedToolbar", + visible + ); + + // Make sure focus is where it belongs. + if (visible) { + if ( + document.activeElement !== this && + !this.contains(document.activeElement) + ) { + Services.focus.moveFocus( + window, + this, + Services.focus.MOVEFOCUS_FIRST, + 0 + ); + } + } else { + Services.focus.moveFocus( + window, + document.body, + Services.focus.MOVEFOCUS_ROOT, + 0 + ); + } + } + + /** + * Check if an item is active in all spaces. + * + * @param {string} itemId - Item ID of the item to check for. + * @returns {boolean} If the given item is found active in all spaces. + */ + activeInAllSpaces(itemId) { + return Array.from( + this.querySelectorAll("unified-toolbar-customization-pane"), + pane => pane.hasItem(itemId) + ).every(hasItem => hasItem); + } + + /** + * Check if an item is active in two or more spaces. + * + * @param {string} itemId - Item ID of the item to check for. + * @returns {boolean} If the given item is active in at least two spaces. + */ + activeInMultipleSpaces(itemId) { + return ( + Array.from( + this.querySelectorAll("unified-toolbar-customization-pane"), + pane => pane.hasItem(itemId) + ).filter(Boolean).length > 1 + ); + } +} +customElements.define( + "unified-toolbar-customization", + UnifiedToolbarCustomization +); diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs new file mode 100644 index 0000000000..134aec6cf1 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs @@ -0,0 +1,119 @@ +/* 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/. */ + +/** + * Template ID: unifiedToolbarTabTemplate + * Attributes: + * - selected: If the tab is active. + * - aria-controls: The ID of the tab pane this controls. + * Events: + * - tabswitch: When the active tab is changed. + */ +class UnifiedToolbarTab extends HTMLElement { + /** + * @type {?HTMLButtonElement} + */ + #tab = null; + + connectedCallback() { + if (this.shadowRoot) { + return; + } + this.setAttribute("role", "presentation"); + const shadowRoot = this.attachShadow({ mode: "open" }); + + const template = document + .getElementById("unifiedToolbarTabTemplate") + .content.cloneNode(true); + this.#tab = template.querySelector("button"); + this.#tab.tabIndex = this.hasAttribute("selected") ? 0 : -1; + if (this.hasAttribute("selected")) { + this.#tab.setAttribute("aria-selected", "true"); + } + this.#tab.setAttribute("aria-controls", this.getAttribute("aria-controls")); + this.removeAttribute("aria-controls"); + + const styles = document.createElement("link"); + styles.setAttribute("rel", "stylesheet"); + styles.setAttribute( + "href", + "chrome://messenger/skin/shared/unifiedToolbarTab.css" + ); + + shadowRoot.append(styles, template); + + this.#tab.addEventListener("click", () => { + this.select(); + }); + this.#tab.addEventListener("keydown", this.#handleKey); + } + + #handleKey = event => { + const rightIsForward = document.dir === "ltr"; + const rightSibling = + (rightIsForward ? "next" : "previous") + "ElementSibling"; + const leftSibling = + (rightIsForward ? "previous" : "next") + "ElementSibling"; + switch (event.key) { + case "ArrowLeft": + this[leftSibling]?.focus(); + break; + case "ArrowRight": + this[rightSibling]?.focus(); + break; + case "Home": + this.parentNode.firstElementChild?.focus(); + break; + case "End": + this.parentNode.lastElementChild?.focus(); + break; + default: + return; + } + + event.stopPropagation(); + event.preventDefault(); + }; + + #toggleTabPane(visible) { + this.pane.hidden = !visible; + } + + /** + * Select this tab. Deselects the previously selected tab and shows the tab + * pane for this tab. + */ + select() { + this.parentElement + .querySelector("unified-toolbar-tab[selected]") + ?.unselect(); + this.#tab.setAttribute("aria-selected", "true"); + this.#tab.tabIndex = 0; + this.setAttribute("selected", true); + this.#toggleTabPane(true); + const tabSwitchEvent = new Event("tabswitch", { + bubbles: true, + }); + this.dispatchEvent(tabSwitchEvent); + } + + /** + * Remove the selection for this tab and hide the associated tab pane. + */ + unselect() { + this.#tab.removeAttribute("aria-selected"); + this.#tab.tabIndex = -1; + this.removeAttribute("selected"); + this.#toggleTabPane(false); + } + + focus() { + this.#tab.focus(); + } + + get pane() { + return document.getElementById(this.#tab.getAttribute("aria-controls")); + } +} +customElements.define("unified-toolbar-tab", UnifiedToolbarTab); diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs new file mode 100644 index 0000000000..e8624750af --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs @@ -0,0 +1,540 @@ +/* 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/. */ + +/* global gSpacesToolbar, ToolbarContextMenu */ + +import { getState } from "resource:///modules/CustomizationState.mjs"; +import { + BUTTON_STYLE_MAP, + BUTTON_STYLE_PREF, +} from "resource:///modules/ButtonStyle.mjs"; +import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + getDefaultItemIdsForSpace: "resource:///modules/CustomizableItems.sys.mjs", + getAvailableItemIdsForSpace: "resource:///modules/CustomizableItems.sys.mjs", + SKIP_FOCUS_ITEM_IDS: "resource:///modules/CustomizableItems.sys.mjs", +}); + +/** + * Unified toolbar container custom element. Used to contain the state + * management and interaction logic. Template: #unifiedToolbarTemplate. + * Requires unifiedToolbarPopups.inc.xhtml to be in a popupset of the same + * document. + */ +class UnifiedToolbar extends HTMLElement { + constructor() { + super(); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "buttonStyle", + BUTTON_STYLE_PREF, + 0, + (preference, prevVal, newVal) => { + if (preference !== BUTTON_STYLE_PREF) { + return; + } + this.classList.remove(prevVal); + this.classList.add(newVal); + }, + value => BUTTON_STYLE_MAP[value] + ); + } + + /** + * List containing the customizable content of the unified toolbar. + * + * @type {?HTMLUListElement} + */ + #toolbarContent = null; + + /** + * The current customization state of the unified toolbar. + * + * @type {?UnifiedToolbarCustomizationState} + */ + #state = null; + + /** + * Arrays of item IDs available in a given space. + * + * @type {object} + */ + #itemsAvailableInSpace = {}; + + /** + * Observer triggered when the state for the unified toolbar is changed. + * + * @type {nsIObserver} + */ + #stateObserver = { + observe: (subject, topic) => { + if (topic === "unified-toolbar-state-change") { + this.initialize(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + + /** + * A MozTabmail tab monitor to listen for tab switch and close events. Calls + * onTabSwitched on currently visible toolbar content and onTabClosing on + * all toolbar content. + * + * @type {object} + */ + #tabMonitor = { + monitorName: "UnifiedToolbar", + onTabTitleChanged() {}, + onTabSwitched: (tab, oldTab) => { + for (const element of this.#toolbarContent.children) { + if (!element.hidden) { + element.onTabSwitched(tab, oldTab); + } + } + }, + onTabOpened() {}, + onTabClosing: tab => { + for (const element of this.#toolbarContent.children) { + element.onTabClosing(tab); + } + }, + onTabPersist() {}, + onTabRestored() {}, + }; + + connectedCallback() { + if (this.hasConnected) { + return; + } + // No shadow root so other stylesheets can style the contents of the + // toolbar, like the window controls. + this.hasConnected = true; + this.classList.add(this.buttonStyle); + const template = document + .getElementById("unifiedToolbarTemplate") + .content.cloneNode(true); + + // TODO Don't show context menu when there is a native one, like for example + // in a search field. + template + .querySelector("#unifiedToolbarContainer") + .addEventListener("contextmenu", this.#handleContextMenu); + this.#toolbarContent = template.querySelector("#unifiedToolbarContent"); + + this.#toolbarContent.addEventListener("keydown", this.#handleKey, { + capture: true, + }); + this.#toolbarContent.addEventListener( + "buttondisabled", + this.#handleButtonDisabled, + { capture: true } + ); + this.#toolbarContent.addEventListener( + "buttonenabled", + this.#handleButtonEnabled, + { capture: true } + ); + + if (gSpacesToolbar.isLoaded) { + this.initialize(); + } else { + window.addEventListener("spaces-toolbar-ready", () => this.initialize(), { + once: true, + }); + document + .getElementById("cmd_CustomizeMailToolbar") + .setAttribute("disabled", true); + } + + this.append(template); + + document + .getElementById("unifiedToolbarCustomize") + .addEventListener("command", this.#handleCustomizeCommand); + + document + .getElementById("menuBarToggleVisible") + .addEventListener("command", this.#handleMenuBarCommand); + + document + .getElementById("spacesToolbar") + .addEventListener("spacechange", this.#handleSpaceChange); + + Services.obs.addObserver( + this.#stateObserver, + "unified-toolbar-state-change", + true + ); + + if (document.readyState === "complete") { + document.getElementById("tabmail").registerTabMonitor(this.#tabMonitor); + return; + } + window.addEventListener( + "load", + () => { + document.getElementById("tabmail").registerTabMonitor(this.#tabMonitor); + }, + { once: true } + ); + } + + disconnectedCallback() { + Services.obs.removeObserver( + this.#stateObserver, + "unified-toolbar-state-change" + ); + + document + .getElementById("unifiedToolbarCustomize") + .removeEventListener("command", this.#handleCustomizeCommand); + + document + .getElementById("spacesToolbar") + .removeEventListener("spacechange", this.#handleSpaceChange); + + document.getElementById("tabmail").unregisterTabMonitor(this.#tabMonitor); + } + + #handleContextMenu = event => { + if (!event.target.closest("#unifiedToolbarContent")) { + return; + } + const customizableElement = event.target.closest( + '[is="customizable-element"]' + ); + if (customizableElement?.hasContextMenu) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const popup = document.getElementById("unifiedToolbarMenu"); + + // If not Mac OS, set checked attribute for menu item, otherwise remove item. + const menuBarMenuItem = document.getElementById("menuBarToggleVisible"); + if (AppConstants.platform != "macosx") { + const menubarToolbar = document.getElementById("toolbar-menubar"); + menuBarMenuItem.setAttribute( + "checked", + menubarToolbar.getAttribute("autohide") != "true" + ); + } else if (menuBarMenuItem) { + menuBarMenuItem.remove(); + // Remove the menubar separator as well. + const menuBarSeparator = document.getElementById( + "menuBarToggleMenuSeparator" + ); + menuBarSeparator.remove(); + } + + popup.openPopupAtScreen(event.screenX, event.screenY, true, event); + if (gSpacesToolbar.isLoaded) { + document + .getElementById("unifiedToolbarCustomize") + .removeAttribute("disabled"); + } else { + document + .getElementById("unifiedToolbarCustomize") + .setAttribute("disabled", true); + } + ToolbarContextMenu.updateExtension(popup); + }; + + #handleCustomizeCommand = () => { + this.showCustomization(); + }; + + #handleMenuBarCommand = () => { + const menubarToolbar = document.getElementById("toolbar-menubar"); + const menuItem = document.getElementById("menuBarToggleVisible"); + + if (menubarToolbar.getAttribute("autohide") != "true") { + menubarToolbar.setAttribute("autohide", "true"); + menuItem.removeAttribute("checked"); + } else { + menuItem.setAttribute("checked", true); + menubarToolbar.removeAttribute("autohide"); + } + Services.xulStore.persist(menubarToolbar, "autohide"); + }; + + #handleSpaceChange = event => { + // Switch to the current space or show a generic default state toolbar. + this.#showToolbarForSpace(event.detail?.name ?? "default"); + }; + + #handleKey = event => { + // Don't handle any key events within menupopups that are children of the + // toolbar contents. + if (event.target.closest("menupopup")) { + return; + } + switch (event.key) { + case "ArrowLeft": + case "ArrowRight": { + event.preventDefault(); + event.stopPropagation(); + const rightIsForward = document.dir !== "rtl"; + //TODO groups split by search bar. + const focusableChildren = Array.from( + this.querySelectorAll( + `li[is="customizable-element"]:not([disabled], .skip-focus)` + ) + ).filter( + element => !element.querySelector(".live-content button[disabled]") + ); + if (!focusableChildren.length) { + return; + } + const activeItem = document.activeElement.closest( + 'li[is="customizable-element"]' + ); + const activeIndex = focusableChildren.indexOf(activeItem); + if (activeIndex === -1) { + return; + } + if (!activeItem) { + focusableChildren[0].focus(); + return; + } + const isForward = rightIsForward === (event.key === "ArrowRight"); + const delta = isForward ? 1 : -1; + const focusableSibling = focusableChildren.at(activeIndex + delta); + if (focusableSibling) { + focusableSibling.tabIndex = 0; + focusableSibling.focus(); + } else if (isForward) { + focusableChildren[0].tabIndex = 0; + focusableChildren[0].focus(); + } else { + focusableChildren.at(-1).tabIndex = 0; + focusableChildren.at(-1).focus(); + } + activeItem.tabIndex = -1; + } + } + }; + + #handleButtonDisabled = () => { + if ( + this.#toolbarContent.querySelector( + 'li[is="customizable-element"]:not(.skip-focus) .live-content button[tabindex="0"]' + ) + ) { + return; + } + const newItem = this.#toolbarContent + .querySelector( + 'li[is="customizable-element"]:not([disabled], .skip-focus) .live-content button:not([disabled])' + ) + ?.closest('li[is="customizable-element"]'); + if (newItem) { + newItem.tabIndex = 0; + } + }; + + #handleButtonEnabled = event => { + if ( + this.#toolbarContent.querySelector( + 'li[is="customizable-element"]:not(.skip-focus) .live-content button[tabindex="0"]' + ) + ) { + return; + } + // If there is currently no focusable button, make the button triggering the + // event available. + const newItem = event.target.closest('li[is="customizable-element"]'); + if (newItem) { + newItem.tabIndex = 0; + } + }; + + /** + * Make sure the customization for unified toolbar is injected into the + * document. + * + * @returns {Promise<void>} + */ + async #ensureCustomizationInserted() { + if (document.querySelector("unified-toolbar-customization")) { + return; + } + await import("./unified-toolbar-customization.mjs"); + const customization = document.createElement( + "unified-toolbar-customization" + ); + document.body.appendChild(customization); + } + + /** + * Get the items currently visible in a given space. Filters out items that + * are part of the state but not visible. + * + * @param {string} space - Name of the space to get the active items for. May + * be "default" to indicate a generic default item set should be produced. + * @returns {string[]} Array of item IDs visible in the given space. + */ + #getItemsForSpace(space) { + if (!this.#state[space]) { + this.#state[space] = lazy.getDefaultItemIdsForSpace(space); + } + if (!this.#itemsAvailableInSpace[space]) { + this.#itemsAvailableInSpace[space] = new Set( + lazy.getAvailableItemIdsForSpace(space, true) + ); + } + return this.#state[space].filter(itemId => + this.#itemsAvailableInSpace[space].has(itemId) + ); + } + + /** + * Show the items for the specified space in the toolbar. Only creates + * missing elements when not already created for another space. + * + * @param {string} space - Name of the space to make visible. May be "default" + * to indicate that a generic default state should be shown instead. + */ + #showToolbarForSpace(space) { + if (!this.#state) { + return; + } + const itemIds = this.#getItemsForSpace(space); + // Handling elements which might occur more than once requires us to keep + // track which existing elements we've already used. + const elementTypeOffset = {}; + let focusableElementSet = false; + const wantedElements = itemIds.map(itemId => { + // We want to re-use existing elements to reduce flicker when switching + // spaces and to preserve widget specific state, like a search string. + const existingElements = this.#toolbarContent.querySelectorAll( + `[item-id="${CSS.escape(itemId)}"]` + ); + const nthChild = elementTypeOffset[itemId] ?? 0; + if (existingElements.length > nthChild) { + const existingElement = existingElements[nthChild]; + elementTypeOffset[itemId] = nthChild + 1; + existingElement.hidden = false; + if ( + !( + existingElement.details?.skipFocus || + lazy.SKIP_FOCUS_ITEM_IDS.has(itemId) + ) && + existingElement.querySelector(".live-content button:not([disabled])") + ) { + if (focusableElementSet) { + existingElement.tabIndex = -1; + } else { + existingElement.tabIndex = 0; + focusableElementSet = true; + } + } + return existingElement; + } + const element = document.createElement("li", { + is: "customizable-element", + }); + element.setAttribute("item-id", itemId); + if (!lazy.SKIP_FOCUS_ITEM_IDS.has(itemId)) { + if (focusableElementSet) { + element.tabIndex = -1; + } else { + element.tabIndex = 0; + focusableElementSet = true; + } + } + return element; + }); + for (const element of this.#toolbarContent.children) { + if (!wantedElements.includes(element)) { + element.hidden = true; + } + } + this.#toolbarContent.append(...wantedElements); + } + + /** + * Initialize the unified toolbar contents. + */ + initialize() { + this.#state = getState(); + this.#itemsAvailableInSpace = {}; + // Remove unused items from the toolbar. + const currentElements = this.#toolbarContent.children; + if (currentElements.length) { + const filledOutState = Object.fromEntries( + (gSpacesToolbar.spaces ?? Object.keys(this.#state)).map(space => [ + space.name, + this.#getItemsForSpace(space.name), + ]) + ); + const allItems = new Set(Object.values(filledOutState).flat()); + const spaceCounts = Object.keys(filledOutState).map(space => + filledOutState[space].reduce((counts, itemId) => { + if (counts[itemId]) { + ++counts[itemId]; + } else { + counts[itemId] = 1; + } + return counts; + }, {}) + ); + const elementCounts = Object.fromEntries( + Array.from(allItems, itemId => [ + itemId, + Math.max(...spaceCounts.map(spaceCount => spaceCount[itemId])), + ]) + ); + const encounteredElements = {}; + for (const element of currentElements) { + const itemId = element.getAttribute("item-id"); + if ( + allItems.has(itemId) && + (!encounteredElements[itemId] || + encounteredElements[itemId] < elementCounts[itemId]) + ) { + encounteredElements[itemId] = encounteredElements[itemId] + ? encounteredElements[itemId] + 1 + : 1; + continue; + } + // We don't need that many of this item. + element.remove(); + } + } + this.#showToolbarForSpace(gSpacesToolbar.currentSpace?.name ?? "default"); + document + .getElementById("cmd_CustomizeMailToolbar") + .removeAttribute("disabled"); + } + + /** + * Opens the customization UI for the unified toolbar. + */ + async showCustomization() { + if (!gSpacesToolbar.isLoaded) { + return; + } + await this.#ensureCustomizationInserted(); + document.querySelector("unified-toolbar-customization").toggle(true); + } + + focus() { + this.firstElementChild.focus(); + } +} +customElements.define("unified-toolbar", UnifiedToolbar); diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml new file mode 100644 index 0000000000..88cb8b0c62 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml @@ -0,0 +1,366 @@ +# 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/. +<html:template id="searchBarItemTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <global-search-bar data-l10n-id="search-bar-item" + data-l10n-attrs="label" + aria-keyshortcuts="Control+K"> + <span slot="placeholder" data-l10n-id="search-bar-placeholder-with-key2"></span> + <img data-l10n-id="search-bar-button" + slot="button" + class="search-button-icon" + src="" /> + </global-search-bar> +</html:template> + +<html:template id="writeMessageTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="cmd_newMessage" + label-id="toolbar-write-message-label" + data-l10n-id="toolbar-write-message"></button> +</html:template> + +<html:template id="moveToTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + popup="toolbarMoveToPopup" + observes="cmd_moveMessage" + label-id="toolbar-move-to-label" + data-l10n-id="toolbar-move-to"></button> +</html:template> + +<html:template id="calendarUnifinderTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="calendar_show_unifinder_command" + aria-pressed="false" + class="check-button" + label-id="toolbar-unifinder-label" + data-l10n-id="toolbar-unifinder"></button> +</html:template> + +<html:template id="folderLocationTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="folder-location-button" + popup="toolbarFolderLocationPopup" + data-l10n-id="toolbar-folder-location"></button> +</html:template> + +<html:template id="editEventTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="calendar_modify_focused_item_command" + label-id="toolbar-edit-event-label" + data-l10n-id="toolbar-edit-event"></button> +</html:template> + +<html:template id="getMessagesTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="cmd_getMsgsForAuthAccounts" + label-id="toolbar-get-messages-label" + data-l10n-id="toolbar-get-messages"></button> +</html:template> + +<html:template id="replyTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_reply" + label-id="toolbar-reply-label" + data-l10n-id="toolbar-reply"></button> +</html:template> + +<html:template id="replyAllTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_replyall" + label-id="toolbar-reply-all-label" + data-l10n-id="toolbar-reply-all"></button> +</html:template> + +<html:template id="replyToListTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="reply-list-button" + command="cmd_replylist" + label-id="toolbar-reply-to-list-label" + data-l10n-id="toolbar-reply-to-list"></button> +</html:template> + +<html:template id="redirectTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_redirect" + label-id="toolbar-redirect-label" + data-l10n-id="toolbar-redirect"></button> +</html:template> + +<html:template id="archiveTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_archive" + label-id="toolbar-archive-label" + data-l10n-id="toolbar-archive"></button> +</html:template> + +<html:template id="conversationTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_openConversation" + label-id="toolbar-conversation-label" + data-l10n-id="toolbar-conversation"></button> +</html:template> + +<html:template id="previousUnreadTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_previousUnreadMsg" + label-id="toolbar-previous-unread-label" + data-l10n-id="toolbar-previous-unread"></button> +</html:template> + +<html:template id="previousTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_previousMsg" + label-id="toolbar-previous-label" + data-l10n-id="toolbar-previous"></button> +</html:template> + +<html:template id="nextUnreadTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_nextUnreadMsg" + label-id="toolbar-next-unread-label" + data-l10n-id="toolbar-next-unread"></button> +</html:template> + +<html:template id="nextTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_nextMsg" + label-id="toolbar-next-label" + data-l10n-id="toolbar-next"></button> +</html:template> + +<html:template id="junkTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_markAsJunk" + label-id="toolbar-junk-label" + data-l10n-id="toolbar-junk"></button> +</html:template> + +<html:template id="deleteTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="delete-button" + label-id="toolbar-delete-label" + data-l10n-id="toolbar-delete-title"></button> +</html:template> + +<html:template id="compactTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="compact-folder-button" + label-id="toolbar-compact-label" + data-l10n-id="toolbar-compact"></button> +</html:template> + +<html:template id="addAsEventTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="add-to-calendar-button" + type="event" + label-id="toolbar-add-as-event-label" + data-l10n-id="toolbar-add-as-event"></button> +</html:template> + +<html:template id="addAsTaskTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="add-to-calendar-button" + type="task" + label-id="toolbar-add-as-task-label" + data-l10n-id="toolbar-add-as-task"></button> +</html:template> + +<html:template id="tagMessageTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + popup="toolbarTagPopup" + observes="cmd_tag" + label-id="toolbar-tag-message-label" + data-l10n-id="toolbar-tag-message"></button> +</html:template> + +<html:template id="forwardInlineTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_forwardInline" + label-id="toolbar-forward-inline-label" + data-l10n-id="toolbar-forward-inline"></button> +</html:template> + +<html:template id="forwardAttachmentTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_forwardAttachment" + label-id="toolbar-forward-attachment-label" + data-l10n-id="toolbar-forward-attachment"></button> +</html:template> + +<html:template id="markAsTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + popup="toolbarMarkPopup" + observes="cmd_tag" + label-id="toolbar-mark-as-label" + data-l10n-id="toolbar-mark-as"></button> +</html:template> + +<html:template id="viewPickerTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="view-picker-button" + popup="toolbarViewPickerPopup" + data-l10n-id="toolbar-view-picker"></button> +</html:template> + +<html:template id="addressBookTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="space-button" + space="addressbook" + label-id="toolbar-address-book-label" + data-l10n-id="toolbar-address-book"></button> +</html:template> + +<html:template id="chatTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="space-button" + space="chat" + label-id="toolbar-chat-label" + data-l10n-id="toolbar-chat"></button> +</html:template> + +<html:template id="addOnsAndThemesTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="addons-button" + label-id="toolbar-add-ons-and-themes-label" + data-l10n-id="toolbar-add-ons-and-themes"></button> +</html:template> + +<html:template id="calendarTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="space-button" + space="calendar" + label-id="toolbar-calendar-label" + data-l10n-id="toolbar-calendar"></button> +</html:template> + +<html:template id="tasksTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="space-button" + space="tasks" + label-id="toolbar-tasks-label" + data-l10n-id="toolbar-tasks"></button> +</html:template> + +<html:template id="mailTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="space-button" + space="mail" + label-id="toolbar-mail-label" + data-l10n-id="toolbar-mail"></button> +</html:template> + +<html:template id="printTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_print" + label-id="toolbar-print-label" + data-l10n-id="toolbar-print"></button> +</html:template> + +<html:template id="quickFilterBarTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="quick-filter-bar-toggle" + command="cmd_toggleQuickFilterBar" + class="check-button" + label-id="toolbar-quick-filter-bar-label" + data-l10n-id="toolbar-quick-filter-bar"></button> +</html:template> + +<html:template id="synchronizeTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="calendar_reload_remote_calendars" + label-id="toolbar-synchronize-label" + data-l10n-id="toolbar-synchronize"></button> +</html:template> + +<html:template id="newEventTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="calendar_new_event_command" + label-id="toolbar-new-event-label" + data-l10n-id="toolbar-new-event"></button> +</html:template> + +<html:template id="newTaskTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="calendar_new_todo_command" + label-id="toolbar-new-task-label" + data-l10n-id="toolbar-new-task"></button> +</html:template> + +<html:template id="goToTodayTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="calendar_go_to_today_command" + observes="calendar_mode_calendar" + label-id="toolbar-go-to-today-label" + data-l10n-id="toolbar-go-to-today"></button> +</html:template> + +<html:template id="deleteEventTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="calendar_delete_focused_item_command" + label-id="toolbar-delete-event-label" + data-l10n-id="toolbar-delete-event"></button> +</html:template> + +<html:template id="printEventTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="unified-toolbar-button" + command="cmd_print" + label-id="toolbar-print-event-label" + data-l10n-id="toolbar-print-event"></button> +</html:template> + +<html:template id="goBackTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-go-button" + direction="back" + label-id="toolbar-go-back-label" + data-l10n-id="toolbar-go-back"></button> +</html:template> + +<html:template id="goForwardTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-go-button" + direction="forward" + label-id="toolbar-go-forward-label" + data-l10n-id="toolbar-go-forward"></button> +</html:template> + +<html:template id="stopTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button is="mail-tab-button" + command="cmd_stop" + label-id="toolbar-stop-label" + data-l10n-id="toolbar-stop"></button> +</html:template> + +<html:template id="throbberTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <img class="throbber-icon" alt="" data-l10n-id="toolbar-throbber" /> +</html:template> diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml new file mode 100644 index 0000000000..b0edb1b67f --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml @@ -0,0 +1,133 @@ +# 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/. +<menupopup id="unifiedToolbarMenu"> + <menuitem id="menuBarToggleVisible" + type="checkbox" + label="&menubarCmd.label;" + accesskey="&menubarCmd.accesskey;"/> + <menuseparator id="menuBarToggleMenuSeparator"/> + <menuitem id="unifiedToolbarCustomize" data-l10n-id="customize-menu-customize" /> + <menuseparator id="extensionsMailToolbarMenuSeparator"/> + <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-manage-extension" + class="customize-context-manageExtension"/> + <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-remove-extension" + class="customize-context-removeExtension"/> +</menupopup> +<menupopup id="customizationTargetMenu"> + <menuitem id="customizationTargetEnd" data-l10n-id="customize-target-end" /> + <menuitem id="customizationTargetForward" data-l10n-id="customize-target-forward" /> + <menuitem id="customizationTargetBackward" data-l10n-id="customize-target-backward" /> + <menuitem id="customizationTargetStart" data-l10n-id="customize-target-start" /> + <menuitem id="customizationTargetRemove" data-l10n-id="customize-target-remove" /> + <menuitem id="customizationTargetRemoveEverywhere" + data-l10n-id="customize-target-remove-everywhere" + hidden="true" /> + <menuitem id="customizationTargetAddEverywhere" + data-l10n-id="customize-target-add-everywhere" + hidden="true" /> +</menupopup> +<menupopup id="customizationPaletteMenu"> + <menuitem id="customizationPaletteAddEverywhere" + data-l10n-id="customize-palette-add-everywhere" + hidden="true" /> +</menupopup> +<menupopup is="folder-menupopup" id="toolbarMoveToPopup" + mode="filing" + showRecent="true" + showFileHereLabel="true" + recentLabel="&moveCopyMsgRecentMenu.label;" + recentAccessKey="&moveCopyMsgRecentMenu.accesskey;" + oncommand="goDoCommand('cmd_moveMessage', event.target._folder);event.stopPropagation()"/> +<menupopup is="folder-menupopup" id="toolbarFolderLocationPopup" + class="menulist-menupopup" + mode="notDeferred" + showFileHereLabel="true"/> +<menupopup id="toolbarTagPopup" + onpopupshowing="InitMessageTags(this);"> + <menuitem id="button-addNewTag" + label="&addNewTag.label;" + accesskey="&addNewTag.accesskey;" + command="cmd_addTag"/> + <menuitem id="button-manageTags" + label="&manageTags.label;" + accesskey="&manageTags.accesskey;" + command="cmd_manageTags"/> + <menuseparator id="button-tagpopup-sep-afterTagAddNew"/> + <menuitem id="button-tagRemoveAll" + command="cmd_removeTags"/> + <menuseparator id="button-afterTagRemoveAllSeparator"/> +</menupopup> +<menupopup id="toolbarMarkPopup" onpopupshowing="InitMessageMark()"> + <menuitem id="markReadToolbarItem" + label="&markAsReadCmd.label;" + accesskey="&markAsReadCmd.accesskey;" + key="key_toggleRead" + command="cmd_markAsRead"/> + <menuitem id="markUnreadToolbarItem" + label="&markAsUnreadCmd.label;" + accesskey="&markAsUnreadCmd.accesskey;" + key="key_toggleRead" + command="cmd_markAsUnread"/> + <menuitem id="button-markThreadAsRead" + label="&markThreadAsReadCmd.label;" + key="key_markThreadAsRead" + accesskey="&markThreadAsReadCmd.accesskey;" + command="cmd_markThreadAsRead"/> + <menuitem id="button-markReadByDate" + label="&markReadByDateCmd.label;" + key="key_markReadByDate" + accesskey="&markReadByDateCmd.accesskey;" + command="cmd_markReadByDate"/> + <menuitem id="button-markAllRead" + label="&markAllReadCmd.label;" + key="key_markAllRead" + accesskey="&markAllReadCmd.accesskey;" + command="cmd_markAllRead"/> + <menuseparator id="button-markAllReadSeparator"/> + <menuitem id="markFlaggedToolbarItem" + type="checkbox" + label="&markStarredCmd.label;" + accesskey="&markStarredCmd.accesskey;" + key="key_toggleFlagged" + command="cmd_markAsFlagged"/> +</menupopup> +<menupopup id="toolbarViewPickerPopup" + onpopupshowing="RefreshViewPopup(this);"> + <menuitem id="viewPickerAll" value="0" + label="&viewAll.label;" + type="radio" + oncommand="ViewChangeByMenuitem(this);"/> + <menuitem id="viewPickerUnread" value="1" + label="&viewUnread.label;" + type="radio" + oncommand="ViewChangeByMenuitem(this);"/> + <menuitem id="viewPickerNotDeleted" value="3" + label="&viewNotDeleted.label;" + type="radio" + oncommand="ViewChangeByMenuitem(this);"/> + <menuseparator id="afterViewPickerUnreadSeparator"/> + <menu id="viewPickerTags" label="&viewTags.label;"> + <menupopup id="viewPickerTagsPopup" + class="menulist-menupopup" + onpopupshowing="RefreshTagsPopup(this);"/> + </menu> + <menu id="viewPickerCustomViews" label="&viewCustomViews.label;"> + <menupopup id="viewPickerCustomViewsPopup" + class="menulist-menupopup" + onpopupshowing="RefreshCustomViewsPopup(this);"/> + </menu> + <menuseparator id="afterViewPickerCustomViewsSeparator"/> + <menuitem id="viewPickerVirtualFolder" + value="7" + label="&viewVirtualFolder.label;" + oncommand="ViewChangeByMenuitem(this);"/> + <menuitem id="viewPickerCustomize" + value="8" + label="&viewCustomizeView.label;" + oncommand="ViewChangeByMenuitem(this);"/> +</menupopup> +<menupopup id="messageHistoryPopup"> +</menupopup> diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml new file mode 100644 index 0000000000..3953ed8871 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml @@ -0,0 +1,137 @@ +# 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/. + +#include ./unifiedToolbarCustomizableItems.inc.xhtml + +<html:template id="searchBarTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <form> + <input type="search" placeholder="" required="required" /> + <div aria-hidden="true"><slot name="placeholder"></slot></div> + <button class="button button-flat icon-button"><slot name="button"></slot></button> + </form> +</html:template> + +<html:template id="unifiedToolbarTemplate"> +# Required for placing the window controls in the proper place without having +# them inside the toolbar. + <html:div id="unifiedToolbarContainer"> + <html:div id="unifiedToolbar" role="toolbar"> +#include ../../../base/content/spacesToolbarPin.inc.xhtml + <html:ul id="unifiedToolbarContent" class="unified-toolbar"> + </html:ul> + <html:div id="notification-popup-box" hidden="true"> + <html:img id="addons-notification-icon" + src="chrome://messenger/skin/icons/new/compact/extension.svg" + alt="" + class="notification-anchor-icon" + role="button" /> + </html:div> + <toolbarbutton id="button-appmenu" + type="menu" + badged="true" + class="button toolbar-button button-appmenu" + label="&appmenuButton.label;" + tooltiptext="&appmenuButton1.tooltip;" + tabindex="0" /> + </html:div> +#include ../../../base/content/messenger-titlebar-items.inc.xhtml + </html:div> +</html:template> + +<html:template id="unifiedToolbarCustomizationTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <form id="unifiedToolbarCustomizationContainer" + aria-labelledby="customizationHeading"> + <h1 id="customizationHeading" data-l10n-id="customize-title"></h1> + <div role="tablist" id="customizationTabs" data-l10n-id="customize-spaces-tabs"></div> + <div id="customizationFooter"> + <div> + <button type="reset" + class="button" + data-l10n-id="customize-restore-default"></button> + <button id="customizationToSettingsButton" + type="button" + class="button link-button" + data-l10n-id="customize-change-appearance"></button> + </div> + <div> + <label id="buttonStyleLabel" + for="buttonStyle" + data-l10n-id="customize-button-style-label"></label> + <select id="buttonStyle" class="select"> + <option value="icons-beside-text" + data-l10n-id="customize-button-style-icons-beside-text-option" + selected="selected"></option> + <option value="icons-above-text" + data-l10n-id="customize-button-style-icons-above-text-option"></option> + <option value="icons-only" + data-l10n-id="customize-button-style-icons-only-option"></option> + <option value="text-only" + data-l10n-id="customize-button-style-text-only-option"></option> + </select> + </div> + <div> + <button id="unifiedToolbarCustomizationCancel" + type="button" + class="button" + data-l10n-id="customize-cancel"></button> + <button type="submit" + class="button button-primary" + data-l10n-id="customize-save" + disabled="disabled"></button> + </div> + </div> + <small id="unifiedToolbarCustomizationUnsavedChanges" + data-l10n-id="customize-unsaved-changes" + hidden="hidden"></small> + </form> +</html:template> + +<html:template id="unifiedToolbarTabTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <button role="tab"> + <img alt="" src="" part="icon" /> + <span><slot></slot></span> + </button> +</html:template> + +<html:template id="unifiedToolbarCustomizationPaneTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <ul is="customization-target" + data-l10n-id="customize-main-toolbar-target" + class="toolbar-target unified-toolbar"></ul> + <search-bar data-l10n-id="customize-search-bar" + data-l10n-attrs="label" + class="palette-search"> + <img data-l10n-id="search-bar-button" + slot="button" + src="" + class="search-button-icon" /> + </search-bar> + <div class="customization-palettes"> + <h2 class="space-specific-title"></h2> + <ul is="customization-palette" class="space-specific-palette"> + </ul> + <h2 data-l10n-id="customize-palette-generic-title" + class="generic-palette-title"></h2> + <ul is="customization-palette" space="all" class="generic-palette"> + </ul> + </div> +</html:template> + +<html:template id="unifiedToolbarCustomizableElementTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <div class="live-content"></div> + <div class="preview"> + <img src="" alt="" class="preview-icon" /> + <span class="preview-label"></span> + </div> +</html:template> + +<html:template id="unifiedToolbarButtonTemplate" + xmlns="http://www.w3.org/1999/xhtml"> + <img class="button-icon" alt="" src="" /> + <span class="button-label"></span> +</html:template> diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css new file mode 100644 index 0000000000..3fcafe9c48 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css @@ -0,0 +1,53 @@ +/* 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/. */ + +/* This file needs to be in content so it can load the moz-extension:// images. */ + +.unified-toolbar .extension-action .button-icon { + height: 16px; + width: 16px; + margin-inline: 1px; + content: var(--webextension-toolbar-image, inherit); +} + +:is(.icons-only, .icons-above-text, .icons-beside-text) .extension-action .prefer-icon-only .button-label { + display: none; +} + +.unified-toolbar .extension-action .button-icon:-moz-lwtheme { + content: var(--webextension-toolbar-image-dark, inherit); +} + +.extension-action .preview-icon { + content: var(--webextension-icon, inherit); +} + +@media (prefers-color-scheme: dark) { + .unified-toolbar .extension-action .button-icon, + :root[lwt-tree-brighttext] .unified-toolbar .extension-action .button-icon { + content: var(--webextension-toolbar-image-light, inherit) !important; + } +} + + +@media (min-resolution: 1.1dppx) { + .unified-toolbar .extension-action .button-icon { + content: var(--webextension-toolbar-image-2x, inherit); + } + + .unified-toolbar .extension-action .button-icon:-moz-lwtheme { + content: var(--webextension-toolbar-image-2x-dark, inherit); + } + + .extension-action .preview-icon { + content: var(--webextension-icon-2x, inherit); + } + + @media (prefers-color-scheme: dark) { + .unified-toolbar .extension-action .button-icon, + :root[lwt-tree-brighttext] .unified-toolbar .extension-action .button-icon { + content: var(--webextension-toolbar-image-2x-light, inherit) !important; + } + } +} diff --git a/comm/mail/components/unifiedtoolbar/jar.mn b/comm/mail/components/unifiedtoolbar/jar.mn new file mode 100644 index 0000000000..ad4478e170 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/jar.mn @@ -0,0 +1,29 @@ +# 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/. + +messenger.jar: + content/messenger/unifiedtoolbar/customizable-element.mjs (content/customizable-element.mjs) + content/messenger/unifiedtoolbar/customization-palette.mjs (content/customization-palette.mjs) + content/messenger/unifiedtoolbar/customization-target.mjs (content/customization-target.mjs) + content/messenger/unifiedtoolbar/add-to-calendar-button.mjs (content/items/add-to-calendar-button.mjs) + content/messenger/unifiedtoolbar/addons-button.mjs (content/items/addons-button.mjs) + content/messenger/unifiedtoolbar/compact-folder-button.mjs (content/items/compact-folder-button.mjs) + content/messenger/unifiedtoolbar/delete-button.mjs (content/items/delete-button.mjs) + content/messenger/unifiedtoolbar/folder-location-button.mjs (content/items/folder-location-button.mjs) + content/messenger/unifiedtoolbar/global-search-bar.mjs (content/items/global-search-bar.mjs) + content/messenger/unifiedtoolbar/mail-go-button.mjs (content/items/mail-go-button.mjs) + content/messenger/unifiedtoolbar/quick-filter-bar-toggle.mjs (content/items/quick-filter-bar-toggle.mjs) + content/messenger/unifiedtoolbar/space-button.mjs (content/items/space-button.mjs) + content/messenger/unifiedtoolbar/view-picker-button.mjs (content/items/view-picker-button.mjs) + content/messenger/unifiedtoolbar/extension-action-button.mjs (content/extension-action-button.mjs) + content/messenger/unifiedtoolbar/list-box-selection.mjs (content/list-box-selection.mjs) + content/messenger/unifiedtoolbar/mail-tab-button.mjs (content/mail-tab-button.mjs) + content/messenger/unifiedtoolbar/reply-list-button.mjs (content/items/reply-list-button.mjs) + content/messenger/unifiedtoolbar/search-bar.mjs (content/search-bar.mjs) + content/messenger/unifiedtoolbar/unified-toolbar.mjs (content/unified-toolbar.mjs) + content/messenger/unifiedtoolbar/unified-toolbar-button.mjs (content/unified-toolbar-button.mjs) + content/messenger/unifiedtoolbar/unified-toolbar-customization.mjs (content/unified-toolbar-customization.mjs) + content/messenger/unifiedtoolbar/unified-toolbar-customization-pane.mjs (content/unified-toolbar-customization-pane.mjs) + content/messenger/unifiedtoolbar/unified-toolbar-tab.mjs (content/unified-toolbar-tab.mjs) + content/messenger/unifiedtoolbar/unifiedToolbarWebextensions.css (content/unifiedToolbarWebextensions.css) diff --git a/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs b/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs new file mode 100644 index 0000000000..c65f3ed16a --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs @@ -0,0 +1,23 @@ +/* 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/. */ + +/** + * Array of button styles with the class name at the index of the corresponding + * button style pref integer value. + * + * @type {Array<string>} + */ +export const BUTTON_STYLE_MAP = [ + "icons-beside-text", + "icons-above-text", + "icons-only", + "text-only", +]; + +/** + * Name of preference that stores the button style as an integer. + * + * @type {string} + */ +export const BUTTON_STYLE_PREF = "toolbar.unifiedtoolbar.buttonstyle"; diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs new file mode 100644 index 0000000000..eb9ccee46f --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs @@ -0,0 +1,134 @@ +/* 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/. */ + +import CUSTOMIZABLE_ITEMS from "resource:///modules/CustomizableItemsDetails.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "glodaEnabled", + "mailnews.database.global.indexer.enabled", + true, + () => Services.obs.notifyObservers(null, "unified-toolbar-state-change") +); + +const DEFAULT_ITEMS = ["spacer", "search-bar", "spacer"]; +const DEFAULT_ITEMS_WITHOUT_SEARCH = ["spacer"]; + +/** + * @type {{id: string, spaces: string[], installDate: Date}[]} + */ +const EXTENSIONS = []; + +export const EXTENSION_PREFIX = "ext-"; + +/** + * Add an extension button that is available in the given spaces. Defaults to + * making the button only available in the mail space. To provide it in all + * spaces, pass an empty array for the spaces. + * + * @param {string} id - Extension ID to add the button for. + * @param {string[]} [spaces=["mail"]] - Array of spaces the button can be used + * in. + */ +export async function registerExtension(id, spaces = ["mail"]) { + if (EXTENSIONS.some(extension => extension.id === id)) { + return; + } + const addon = await lazy.AddonManager.getAddonByID(id); + EXTENSIONS.push({ + id, + spaces, + installDate: addon?.installDate ?? new Date(), + }); + EXTENSIONS.sort( + (extA, extB) => extA.installDate.valueOf() - extB.installDate.valueOf() + ); +} + +/** + * Remove the extension from the palette of available items. + * + * @param {string} id - Extension ID to remove. + */ +export function unregisterExtension(id) { + const index = EXTENSIONS.findIndex(extension => extension.id === id); + EXTENSIONS.splice(index, 1); +} + +/** + * Get the IDs for the extension buttons available in a given space. + * + * @param {string} [space] - Space name, "default" or falsy value to specify the + * space the extension items should be returned for. For default, extensions + * explicitly available in the default space are returned. With a falsy value, + * extensions available in all spaces are returned. + * @param {boolean} [includeSpaceAgnostic=false] - When set, extensions that are + * available for all spaces and the provided space are returned. Only has an + * effect if space is not falsy. + * @returns {string[]} Array of item IDs for extensions in the given space. + */ +function getExtensionsForSpace(space, includeSpaceAgnostic = false) { + return EXTENSIONS.filter( + extension => + (space && extension.spaces?.includes(space)) || + ((!space || includeSpaceAgnostic) && !extension.spaces?.length) + ).map(extension => `${EXTENSION_PREFIX}${extension.id}`); +} + +/** + * Get the items available for the unified toolbar in a given space. + * + * @param {string} [space] - ID of the space to get the available exclusive + * items of. When omitted only items allowed in all spaces are returned. + * @param {boolean} [includeSpaceAgnostic=false] - When set, extensions that are + * available for all spaces and the provided space are returned. Only has an + * effect if space is not falsy. + * @returns {string[]} Array of item IDs available in the space. + */ +export function getAvailableItemIdsForSpace( + space, + includeSpaceAgnostic = false +) { + return CUSTOMIZABLE_ITEMS.filter( + item => + ((space && item.spaces?.includes(space)) || + ((!space || includeSpaceAgnostic) && + (!item.spaces || item.spaces.length === 0))) && + (item.id !== "search-bar" || lazy.glodaEnabled) + ) + .map(item => item.id) + .concat(getExtensionsForSpace(space, includeSpaceAgnostic)); +} + +/** + * Retrieve the set of items that are in the default configuration of the + * toolbar for a given space. + * + * @param {string} space - ID of the space to get the default items for. + * "default" is passed to indicate a default state without any active space. + * @returns {string[]} Array of item IDs to show by default in the space. + */ +export function getDefaultItemIdsForSpace(space) { + return ( + lazy.glodaEnabled ? DEFAULT_ITEMS : DEFAULT_ITEMS_WITHOUT_SEARCH + ).concat(getExtensionsForSpace(space, true)); +} + +/** + * Set of item IDs that can occur more than once in the targets of a space. + * + * @type {Set<string>} + */ +export const MULTIPLE_ALLOWED_ITEM_IDS = new Set( + CUSTOMIZABLE_ITEMS.filter(item => item.allowMultiple).map(item => item.id) +); + +export const SKIP_FOCUS_ITEM_IDS = new Set( + CUSTOMIZABLE_ITEMS.filter(item => item.skipFocus).map(item => item.id) +); diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs new file mode 100644 index 0000000000..1e3900d6c3 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs @@ -0,0 +1,445 @@ +/* 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/. */ + +/* This has the following companion definition files: + * - unifiedToolbarCustomizableItems.css for the preview icons based on the id. + * - unifiedToolbarItems.ftl for the labels associated with the labelId. + * - unifiedToolbarCustomizableItems.inc.xhtml for the templates referenced with + * templateId. + * - unifiedToolbarShared.css contains styles for the template contents shared + * between the customization preview and the actual toolbar. + * - unifiedtoolbar/content/items contains all item specific custom elements. + */ + +/** + * @typedef {object} CustomizableItemDetails + * @property {string} id - The ID of the item. Will be set as a class on the + * outer wrapper. May not contain commas. + * @property {string} labelId - Fluent ID for the label shown while in the + * palette. + * @property {boolean} [allowMultiple] - If this item can be added more than + * once to a space. + * @property {string[]} [spaces] - If empty or omitted, item is allowed in all + * spaces. + * @property {string} [templateId] - ID of template defining the "live" markup. + * @property {string[]} [requiredModules] - List of modules that must be loaded + * for the template of this item. + * @property {boolean} [hasContextMenu] - Indicates that this item has its own + * context menu, and the global unified toolbar one shouldn't be shown. + * @property {boolean} [skipFocus] - If this item should be skipped in keyboard + * focus navigation. + */ + +/** + * @type {CustomizableItemDetails[]} + */ +export default [ + // Universal items (all spaces) + { + id: "spacer", + labelId: "spacer", + allowMultiple: true, + skipFocus: true, + }, + { + // This item gets filtered out when gloda is disabled. + id: "search-bar", + labelId: "search-bar", + templateId: "searchBarItemTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/global-search-bar.mjs", + ], + hasContextMenu: true, + skipFocus: true, + }, + { + id: "write-message", + labelId: "toolbar-write-message", + templateId: "writeMessageTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + { + id: "get-messages", + labelId: "toolbar-get-messages", + templateId: "getMessagesTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + { + id: "address-book", + labelId: "toolbar-address-book", + templateId: "addressBookTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/space-button.mjs", + ], + }, + { + id: "chat", + labelId: "toolbar-chat", + templateId: "chatTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/space-button.mjs", + ], + }, + { + id: "add-ons-and-themes", + labelId: "toolbar-add-ons-and-themes", + templateId: "addOnsAndThemesTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/addons-button.mjs", + ], + }, + { + id: "calendar", + labelId: "toolbar-calendar", + templateId: "calendarTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/space-button.mjs", + ], + }, + { + id: "tasks", + labelId: "toolbar-tasks", + templateId: "tasksTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/space-button.mjs", + ], + }, + { + id: "mail", + labelId: "toolbar-mail", + templateId: "mailTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/space-button.mjs", + ], + }, + { + id: "new-event", + labelId: "toolbar-new-event", + templateId: "newEventTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + { + id: "new-task", + labelId: "toolbar-new-task", + templateId: "newTaskTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + // Mail space + { + id: "move-to", + labelId: "toolbar-move-to", + spaces: ["mail"], + templateId: "moveToTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "reply", + labelId: "toolbar-reply", + spaces: ["mail"], + templateId: "replyTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "reply-all", + labelId: "toolbar-reply-all", + spaces: ["mail"], + templateId: "replyAllTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "reply-to-list", + labelId: "toolbar-reply-to-list", + spaces: ["mail"], + templateId: "replyToListTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/reply-list-button.mjs", + ], + }, + { + id: "redirect", + labelId: "toolbar-redirect", + spaces: ["mail"], + templateId: "redirectTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "archive", + labelId: "toolbar-archive", + spaces: ["mail"], + templateId: "archiveTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "conversation", + labelId: "toolbar-conversation", + spaces: ["mail"], + templateId: "conversationTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "previous-unread", + labelId: "toolbar-previous-unread", + spaces: ["mail"], + templateId: "previousUnreadTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "previous", + labelId: "toolbar-previous", + spaces: ["mail"], + templateId: "previousTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "next-unread", + labelId: "toolbar-next-unread", + spaces: ["mail"], + templateId: "nextUnreadTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "next", + labelId: "toolbar-next", + spaces: ["mail"], + templateId: "nextTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "junk", + labelId: "toolbar-junk", + spaces: ["mail"], + templateId: "junkTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "delete", + labelId: "toolbar-delete", + spaces: ["mail"], + templateId: "deleteTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/delete-button.mjs", + ], + }, + { + id: "compact", + labelId: "toolbar-compact", + spaces: ["mail"], + templateId: "compactTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/compact-folder-button.mjs", + ], + }, + { + id: "add-as-event", + labelId: "toolbar-add-as-event", + spaces: ["mail"], + templateId: "addAsEventTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/add-to-calendar-button.mjs", + ], + }, + { + id: "add-as-task", + labelId: "toolbar-add-as-task", + spaces: ["mail"], + templateId: "addAsTaskTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/add-to-calendar-button.mjs", + ], + }, + { + id: "folder-location", + labelId: "toolbar-folder-location", + spaces: ["mail"], + templateId: "folderLocationTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/folder-location-button.mjs", + ], + }, + { + id: "tag-message", + labelId: "toolbar-tag-message", + spaces: ["mail"], + templateId: "tagMessageTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "forward-inline", + labelId: "toolbar-forward-inline", + spaces: ["mail"], + templateId: "forwardInlineTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "forward-attachment", + labelId: "toolbar-forward-attachment", + spaces: ["mail"], + templateId: "forwardAttachmentTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "mark-as", + labelId: "toolbar-mark-as", + spaces: ["mail"], + templateId: "markAsTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "view-picker", + labelId: "toolbar-view-picker", + spaces: ["mail"], + templateId: "viewPickerTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/view-picker-button.mjs", + ], + }, + { + id: "print", + labelId: "toolbar-print", + spaces: ["mail"], + templateId: "printTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "quick-filter-bar", + labelId: "toolbar-quick-filter-bar", + spaces: ["mail"], + templateId: "quickFilterBarTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/quick-filter-bar-toggle.mjs", + ], + }, + { + id: "go-back", + labelId: "toolbar-go-back", + spaces: ["mail"], + templateId: "goBackTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-go-button.mjs", + ], + hasContextMenu: true, + }, + { + id: "go-forward", + labelId: "toolbar-go-forward", + spaces: ["mail"], + templateId: "goForwardTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-go-button.mjs", + ], + hasContextMenu: true, + }, + { + id: "stop", + labelId: "toolbar-stop", + spaces: ["mail"], + templateId: "stopTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs", + ], + }, + { + id: "throbber", + labelId: "toolbar-throbber", + spaces: ["mail"], + templateId: "throbberTemplate", + skipFocus: true, + }, + // Calendar & Tasks space + { + id: "edit-event", + labelId: "toolbar-edit-event", + spaces: ["calendar", "tasks"], + templateId: "editEventTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + { + id: "synchronize", + labelId: "toolbar-synchronize", + spaces: ["calendar", "tasks"], + templateId: "synchronizeTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + { + id: "delete-event", + labelId: "toolbar-delete-event", + spaces: ["calendar", "tasks"], + templateId: "deleteEventTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + { + id: "print-event", + labelId: "toolbar-print-event", + spaces: ["calendar", "tasks"], + templateId: "printEventTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + // Calendar space + { + id: "go-to-today", + labelId: "toolbar-go-to-today", + spaces: ["calendar"], + templateId: "goToTodayTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, + { + id: "unifinder", + labelId: "toolbar-unifinder", + spaces: ["calendar"], + templateId: "calendarUnifinderTemplate", + requiredModules: [ + "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs", + ], + }, +]; diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs new file mode 100644 index 0000000000..75c0b390be --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs @@ -0,0 +1,55 @@ +/* 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/. */ + +const MAIN_WINDOW_DOCUMENT = "chrome://messenger/content/messenger.xhtml"; +const UNIFIED_TOOLBAR_ID = "unifiedToolbar"; +const CUSTOMIZATION_ATTRIBUTE_NAME = "state"; + +/** + * @typedef {object} UnifiedToolbarCustomizationState + * @property {string[]} (spaceName) - Each space has a key on the object, + * containing an ordered array of item IDs. + */ + +/** + * Store the customization state for the unified toolbar. Sends a global + * observer notification. + * + * @param {UnifiedToolbarCustomizationState} state + */ +export function storeState(state) { + Services.xulStore.setValue( + MAIN_WINDOW_DOCUMENT, + UNIFIED_TOOLBAR_ID, + CUSTOMIZATION_ATTRIBUTE_NAME, + JSON.stringify(state) + ); + Services.obs.notifyObservers(null, "unified-toolbar-state-change"); +} + +/** + * Retrieve the customization state of the unified toolbar. + * + * @returns {UnifiedToolbarCustomizationState} A partial representation of the + * customization state of the unified toolbar. Missing spaces are in their + * default states. + */ +export function getState() { + let state = {}; + if ( + Services.xulStore.hasValue( + MAIN_WINDOW_DOCUMENT, + UNIFIED_TOOLBAR_ID, + CUSTOMIZATION_ATTRIBUTE_NAME + ) + ) { + const rawState = Services.xulStore.getValue( + MAIN_WINDOW_DOCUMENT, + UNIFIED_TOOLBAR_ID, + CUSTOMIZATION_ATTRIBUTE_NAME + ); + state = JSON.parse(rawState); + } + return state; +} diff --git a/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs b/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs new file mode 100644 index 0000000000..117fb774ba --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs @@ -0,0 +1,419 @@ +/* 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/. */ + +import { + getState, + storeState, +} from "resource:///modules/CustomizationState.mjs"; +import { + MULTIPLE_ALLOWED_ITEM_IDS, + EXTENSION_PREFIX, + getAvailableItemIdsForSpace, + getDefaultItemIdsForSpace, +} from "resource:///modules/CustomizableItems.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + getCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm", + setCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm", +}); + +/** + * Maps XUL toolbar item IDs to unified toolbar item IDs. If null, the item is + * not available in the unified toolbar. + */ +const MIGRATION_MAP = { + separator: null, + spacer: "spacer", + spring: "spacer", + "button-getmsg": "get-messages", + "button-newmsg": "write-message", + "button-reply": "reply", + "button-replyall": "reply-all", + "button-replylist": "reply-list", + "button-forward": "forward-inline", + "button-redirect": "redirect", + "button-file": "move-to", + "button-archive": "archive", + "button-showconversation": "conversation", + "button-goback": "go-back", + "button-goforward": "go-forward", + "button-previous": "previous-unread", + "button-previousMsg": "previous", + "button-next": "next-unread", + "button-nextMsg": "next", + "button-junk": "junk", + "button-delete": "delete", + "button-print": "print", + "button-mark": "mark-as", + "button-tag": "tag-message", + "qfb-show-filter-bar": "quick-filter-bar", + "button-address": "address-book", + "button-chat": "chat", + "throbber-box": "throbber", + "button-stop": "stop", + "button-compact": "compact", + "folder-location-container": "folder-location", + "mailviews-container": "view-picker", + "button-addons": "add-ons-and-themes", + "button-appmenu": null, + "gloda-search": "search-bar", + "lightning-button-calendar": "calendar", + "lightning-button-tasks": "tasks", + extractEventButton: "add-as-event", + extractTaskButton: "add-as-task", + "menubar-items": null, + "calendar-synchronize-button": "synchronize", + "calendar-newevent-button": "new-event", + "calendar-newtask-button": "new-task", + "calendar-goto-today-button": "go-to-today", + "calendar-edit-button": "edit-event", + "calendar-delete-button": "delete-event", + "calendar-print-button": "print-event", + "calendar-unifinder-button": "unifinder", + "calendar-appmenu-button": null, + "task-synchronize-button": "synchronize", + "task-newevent-button": "new-event", + "task-newtask-button": "new-task", + "task-edit-button": "edit-event", + "task-delete-button": "delete-event", + "task-print-button": "print-event", + "task-appmenu-button": null, +}; + +/** + * Maps space names to the ID of the toolbar in the messenger window. + */ +const TOOLBAR_FOR_SPACE = { + mail: "mail-bar3", + calendar: "calendar-toolbar2", + tasks: "task-toolbar2", +}; + +/** + * XUL toolbars store a special value when there are no items in the toolbar. + */ +const EMPTY_SET = "__empty"; +/** + * Map from the XUL toolbar id to its default set. Since toolbars we're + * migrating were removed from the DOM. The value should be the value of the + * defaultset attribute of the respective element in the markup. + * + * @type {{[string]: string}} + */ +const XUL_TOOLBAR_DEFAULT_SET = { + "mail-bar3": + AppConstants.platform == "macosx" + ? "button-getmsg,button-newmsg,button-tag,qfb-show-filter-bar,spring,gloda-search,button-appmenu" + : "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,button-appmenu", + "tabbar-toolbar": "", + "toolbar-menubar": "menubar-items,spring", + "calendar-toolbar2": + "calendar-synchronize-button,calendar-newevent-button,calendar-newtask-button,calendar-edit-button,calendar-delete-button,spring,calendar-appmenu-button", + "task-toolbar2": + "task-synchronize-button,task-newevent-button,task-newtask-button,task-edit-button,task-delete-button,spring,task-appmenu-button", +}; +const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml"; +const EXTENSION_WIDGET_SUFFIX = "-browserAction-toolbarbutton"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "extensionIds", + "extensions.webextensions.uuids", + "{}", + null, + value => Object.keys(JSON.parse(value)) +); + +/** + * Get the extension ID from a XUL toolbar button ID of an extension. + * + * @param {string} buttonId - ID of the XUL toolbar button. + * @returns {?string} ID of the extension the button belonged to. + */ +function getExtensionIdFromExtensionButton(buttonId) { + const widgetId = buttonId.slice(0, -EXTENSION_WIDGET_SUFFIX.length); + return lazy.extensionIds.find( + extensionId => lazy.ExtensionCommon.makeWidgetId(extensionId) === widgetId + ); +} + +/** + * Convert the string contents of an old toolbar *set attribute to an array of + * item IDs. + * + * @param {string} setString - Contents of the set attribute. + * @returns {string[]} Array of items in the set. + */ +function toolbarSetAttributeToArray(setString) { + if (!setString || setString === EMPTY_SET) { + return []; + } + return setString.split(",").filter(Boolean); +} + +/** + * Get the default set (without extensions) of a XUL toolbar. + * + * @param {string} toolbarId - ID of the XUL toolbar element. + * @param {string} window - URI of the window the toolbar is in. + * @returns {string} defaultset attribute of the given XUL toolbar. + */ +function getOldToolbarDefaultContents(toolbarId, window = MESSENGER_WINDOW) { + let setString = Services.xulStore.getValue(window, toolbarId, "defaultset"); + if (!setString) { + setString = XUL_TOOLBAR_DEFAULT_SET[toolbarId]; + } + return setString; +} + +/** + * Get the items in a XUL toolbar area. Will return defaults if the area is not + * customized. + * + * @param {string} toolbarId - ID of the XUL toolbar element. + * @param {string} window - URI of the window the toolbar is in. + * @returns {string[]} Item IDs in the given XUL toolbar. + */ +function getOldToolbarContents(toolbarId, window = MESSENGER_WINDOW) { + let setString = Services.xulStore.getValue(window, toolbarId, "currentset"); + if (!setString) { + setString = getOldToolbarDefaultContents(toolbarId, window); + } + return toolbarSetAttributeToArray(setString); +} + +/** + * Converts XUL toolbar item IDs to unified toolbar item IDs, filtering out + * items that are not supported in the unified toolbar. + * + * @param {string[]} items - XUL toolbar item IDs to convert. + * @returns {string[]} Unified toolbar item IDs. + */ +function convertContents(items) { + return items + .map(itemId => { + if (MIGRATION_MAP.hasOwnProperty(itemId)) { + return MIGRATION_MAP[itemId]; + } + if (itemId.endsWith(EXTENSION_WIDGET_SUFFIX)) { + const extensionId = getExtensionIdFromExtensionButton(itemId); + if (extensionId) { + return `${EXTENSION_PREFIX}${extensionId}`; + } + } + return null; + }) + .filter(Boolean); +} + +/** + * Get the unified toolbar item IDs for items that were in the tab bar and the + * menu bar areas. + * + * @returns {string[]} Item IDs that were available in any tab in the XUL + * toolbars. + */ +function getGlobalItems() { + const tabsContent = convertContents(getOldToolbarContents("tabbar-toolbar")); + const menubarContent = convertContents( + getOldToolbarContents("toolbar-menubar") + ); + return [...menubarContent, ...tabsContent]; +} + +/** + * Converts the items in the old xul toolbar of a given space and the tab bar + * and menu bar areas to unified toolbar item IDs. + * + * Filters out any items not available and items that appear multiple times, if + * they can't be repeated. The first instance is kept. + * + * If there is no old toolbar for the given space, only the global items are + * returned. + * + * @param {string} space - Name of the space to get the items for. + * @returns {string[]} Unified toolbar item IDs based on the old contents of the + * xul toolbar of the space. + */ +function getItemsForSpace(space) { + let spaceContent = []; + if (TOOLBAR_FOR_SPACE.hasOwnProperty(space)) { + spaceContent = convertContents( + getOldToolbarContents(TOOLBAR_FOR_SPACE[space]) + ); + } else { + spaceContent = getDefaultItemIdsForSpace(space); + } + const newContents = [...spaceContent, ...getGlobalItems()]; + const availableItems = getAvailableItemIdsForSpace(space, true).concat( + lazy.extensionIds.map(id => `${EXTENSION_PREFIX}${id}`) + ); + const encounteredItems = new Set(); + const finalItems = newContents.filter((itemId, index, items) => { + if ( + (encounteredItems.has(itemId) && + !MULTIPLE_ALLOWED_ITEM_IDS.has(itemId)) || + !availableItems.includes(itemId) || + (itemId === "spacer" && index > 0 && items[index - 1] === itemId) + ) { + return false; + } + encounteredItems.add(itemId); + return true; + }); + return finalItems; +} + +/** + * Convert the persisted extensions from the old extensionset to the new space + * specific store for extensions. + * + * @param {string} space - Name of the migrated space. + */ +function convertExtensionState(space) { + if ( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + TOOLBAR_FOR_SPACE[space], + "extensionset" + ) + ) { + return; + } + const extensionSet = Services.xulStore + .getValue(MESSENGER_WINDOW, TOOLBAR_FOR_SPACE[space], "extensionset") + .split(",") + .filter(Boolean); + const extensionsInExtensionSet = extensionSet.map(buttonId => + getExtensionIdFromExtensionButton(buttonId) + ); + const cachedAllowedSpaces = lazy.getCachedAllowedSpaces(); + for (const extensionId of extensionsInExtensionSet) { + const allowedSpaces = cachedAllowedSpaces.get(extensionId) ?? []; + if (!allowedSpaces.includes(space)) { + allowedSpaces.push(space); + } + cachedAllowedSpaces.set(extensionId, allowedSpaces); + } + lazy.setCachedAllowedSpaces(cachedAllowedSpaces); +} + +/** + * Check if the XUL toolbar matches the default state. + * + * @param {string} toolbarId - ID of the old XUL toolbar element to check the + * state of. + * @returns {boolean} If the toolbar with the given ID has a currentset matching + * the default state for that toolbar. + */ +function oldToolbarContainsDefaultItems(toolbarId) { + // Fast path: if there is no current set, the contents of the toolbar were + // never modified. + if (!Services.xulStore.hasValue(MESSENGER_WINDOW, toolbarId, "currentset")) { + return true; + } + const toolbarContents = getOldToolbarContents(toolbarId); + let defaultContents = toolbarSetAttributeToArray( + getOldToolbarDefaultContents(toolbarId) + ); + const extensionContents = toolbarSetAttributeToArray( + Services.xulStore.getValue(MESSENGER_WINDOW, toolbarId, "extensionset") + ); + // Extensions are inserted before the appmenu button, which is usually at the + // end of the default set. + if (extensionContents.length) { + const appmenuIndex = defaultContents.findIndex( + itemId => itemId === "button-appmenu" + ); + if (appmenuIndex !== -1) { + defaultContents.splice(appmenuIndex, 0, ...extensionContents); + } else { + defaultContents = defaultContents.concat(extensionContents); + } + } + return ( + toolbarContents.length === defaultContents.length && + toolbarContents.every((itemId, index) => itemId === defaultContents[index]) + ); +} + +/** + * Check if the XUL toolbar customization state is equivalent to its default set + * for a given space. + * + * @param {string} space - Name of the space to check the default set for. + * @returns {boolean} If the state of the old XUL toolbars matches the default + * set for that space. True if we don't know any toolbar for the given space. + */ +function stateMatchesDefault(space) { + if (!TOOLBAR_FOR_SPACE.hasOwnProperty(space)) { + return true; + } + if (!oldToolbarContainsDefaultItems(TOOLBAR_FOR_SPACE[space])) { + return false; + } + if (space === "mail") { + if (!oldToolbarContainsDefaultItems("tabbar-toolbar")) { + return false; + } + if (!oldToolbarContainsDefaultItems("toolbar-menubar")) { + return false; + } + } + return true; +} + +/** + * Remove all the persisted state of a XUL toolbar from the XUL store. + * + * @param {string} toolbarId - Element ID of the XUL toolbar to clear the state + * of. + */ +export function clearXULToolbarState(toolbarId) { + Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "currentset"); + Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "defaultset"); + Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "extensionset"); +} + +/** + * Migrate the old xul toolbar contents for a given space to the unified toolbar + * if the unified toolbar has not yet been customized. + * + * Adds both the contents of the space specific toolbar and the tab bar and menu + * bar areas to the unified toolbar, if the items are available. + * + * When the migration is complete, the old XUL store values for the XUL toolbar + * area are deleted. + * + * @param {string} space - Name of the space to migrate. + */ +export function migrateToolbarForSpace(space) { + const state = getState(); + // If the mail toolbar areas are all in their default state, we don't want to + // migrate their contents. + const mailToolbarInDefaultState = + space === "mail" && stateMatchesDefault(space); + // Don't migrate contents if the state of the space is already customized. + if (state[space] || mailToolbarInDefaultState) { + if (mailToolbarInDefaultState && TOOLBAR_FOR_SPACE.hasOwnProperty(space)) { + clearXULToolbarState(TOOLBAR_FOR_SPACE[space]); + } + return; + } + state[space] = getItemsForSpace(space); + storeState(state); + if (TOOLBAR_FOR_SPACE.hasOwnProperty(space)) { + convertExtensionState(space); + // Remove all the state for the old toolbar of the space. + clearXULToolbarState(TOOLBAR_FOR_SPACE[space]); + } +} diff --git a/comm/mail/components/unifiedtoolbar/moz.build b/comm/mail/components/unifiedtoolbar/moz.build new file mode 100644 index 0000000000..106b510067 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/moz.build @@ -0,0 +1,22 @@ +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "modules/ButtonStyle.mjs", + "modules/CustomizableItems.sys.mjs", + "modules/CustomizableItemsDetails.mjs", + "modules/CustomizationState.mjs", + "modules/ToolbarMigration.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.ini", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "test/unit/xpcshell.ini", +] diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser.ini b/comm/mail/components/unifiedtoolbar/test/browser/browser.ini new file mode 100644 index 0000000000..072a4571ef --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/browser/browser.ini @@ -0,0 +1,16 @@ +[DEFAULT] +prefs = + mail.provider.suppress_dialog_on_startup=true + mail.spellcheck.inline=false + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.database.global.indexer.enabled=false + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +support-files = files/** + +[browser_customizableItems.js] +[browser_searchBar.js] +[browser_toolbarMigration.js] +[browser_unifiedToolbarTab.js] diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js new file mode 100644 index 0000000000..152ade47f3 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js @@ -0,0 +1,173 @@ +/* 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"; + +const { + getAvailableItemIdsForSpace, + getDefaultItemIdsForSpace, + registerExtension, + unregisterExtension, +} = ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs"); + +add_task(async function test_extensionRegisterUnregisterDefault() { + const extensionId = "thunderbird-compact-light@mozilla.org"; + await registerExtension(extensionId); + + const itemId = `ext-${extensionId}`; + ok( + getAvailableItemIdsForSpace("mail").includes(itemId), + "Extension item available in mail space" + ); + ok( + getDefaultItemIdsForSpace("mail").includes(itemId), + "Extension item in mail space by default" + ); + ok( + !getAvailableItemIdsForSpace().includes(itemId), + "Extension item not available in all spaces" + ); + + unregisterExtension(extensionId); + + ok( + !getAvailableItemIdsForSpace("mail").includes(itemId), + "Extension item no longer available in mail space" + ); + ok( + !getDefaultItemIdsForSpace("mail").includes(itemId), + "Extension item not in mail space by default" + ); +}); + +add_task(async function test_extensionRegisterAllSpaces() { + const extensionId = "thunderbird-compact-light@mozilla.org"; + await registerExtension(extensionId, []); + + const itemId = `ext-${extensionId}`; + ok( + getAvailableItemIdsForSpace().includes(itemId), + "Extension item available in all spaces" + ); + ok( + getDefaultItemIdsForSpace("default").includes(itemId), + "Extension item in all spaces by default" + ); + ok( + !getAvailableItemIdsForSpace("mail").includes(itemId), + "Extension item not available in mail space" + ); + ok( + getDefaultItemIdsForSpace("mail").includes(itemId), + "Extension item in mail space by default" + ); + + unregisterExtension(extensionId); + + ok( + !getAvailableItemIdsForSpace().includes(itemId), + "Extension item no longer available in all spaces" + ); + ok( + !getDefaultItemIdsForSpace("default").includes(itemId), + "Extension item not in any space by default" + ); +}); + +add_task(async function test_extensionRegisterMultipleSpaces() { + const extensionId = "thunderbird-compact-light@mozilla.org"; + await registerExtension(extensionId, ["mail", "calendar", "default"]); + + const itemId = `ext-${extensionId}`; + ok( + getAvailableItemIdsForSpace("calendar").includes(itemId), + "Extension item available in calendar space" + ); + ok( + getDefaultItemIdsForSpace("calendar").includes(itemId), + "Extension item in calendar space by default" + ); + ok( + getAvailableItemIdsForSpace("mail").includes(itemId), + "Extension item available in mail space" + ); + ok( + getDefaultItemIdsForSpace("mail").includes(itemId), + "Extension item in mail space by default" + ); + ok( + !getAvailableItemIdsForSpace().includes(itemId), + "Extension item not available in all spaces" + ); + ok( + getAvailableItemIdsForSpace("default").includes(itemId), + "Extension item available in default space" + ); + ok( + getDefaultItemIdsForSpace("default").includes(itemId), + "Extension item in default space" + ); + + unregisterExtension(extensionId); + + ok( + !getAvailableItemIdsForSpace("mail").includes(itemId), + "Extension item no longer available in mail space" + ); + ok( + !getDefaultItemIdsForSpace("mail").includes(itemId), + "Extension item not in mail space by default" + ); + ok( + !getAvailableItemIdsForSpace("calendar").includes(itemId), + "Extension item no longer available in calendar space" + ); + ok( + !getDefaultItemIdsForSpace("calendar").includes(itemId), + "Extension item not in calendar space by default" + ); + ok( + !getAvailableItemIdsForSpace().includes(itemId), + "Extension item not available in all spaces" + ); + ok( + !getAvailableItemIdsForSpace("default").includes(itemId), + "Extension item not available in default space" + ); + ok( + !getDefaultItemIdsForSpace("default").includes(itemId), + "Extension item not in default space" + ); +}); + +add_task(async function test_extensionRegisterStableOrder() { + const extension1Id = "thunderbird-compact-light@mozilla.org"; + const extension2Id = "thunderbird-compact-dark@mozilla.org"; + await registerExtension(extension1Id); + await registerExtension(extension2Id); + + const defaultItems = getDefaultItemIdsForSpace("mail"); + + const firstExtensionId = defaultItems + .find(itemId => itemId.startsWith("ext-")) + .slice(4); + + unregisterExtension(firstExtensionId); + + ok( + !getDefaultItemIdsForSpace("mail").includes(`ext-${firstExtensionId}`), + "Extension that was the first in the default set not in default set" + ); + + await registerExtension(firstExtensionId); + + Assert.deepEqual( + getDefaultItemIdsForSpace("mail"), + defaultItems, + "Default items order stable for extensions" + ); + + unregisterExtension(extension1Id); + unregisterExtension(extension2Id); +}); diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js new file mode 100644 index 0000000000..b88c16f684 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js @@ -0,0 +1,263 @@ +/* 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/. */ + +let tabmail = document.getElementById("tabmail"); +registerCleanupFunction(() => { + tabmail.closeOtherTabs(tabmail.tabInfo[0]); +}); +let browser; +let searchBar; + +const waitForRender = () => { + return new Promise(resolve => { + window.requestAnimationFrame(resolve); + }); +}; + +/* These are shadow-root safe variants of the methods in BrowserTestUtils. */ + +/** + * Checks if a DOM element is hidden. + * + * @param {Element} element + * The element which is to be checked. + * + * @return {boolean} + */ +function is_hidden(element) { + var style = element.ownerGlobal.getComputedStyle(element); + if (style.display == "none") { + return true; + } + if (style.visibility != "visible") { + return true; + } + if (style.display == "-moz-popup") { + return ["hiding", "closed"].includes(element.state); + } + + // Hiding a parent element will hide all its children + if (element.parentNode != element.ownerDocument && element.parentElement) { + return is_hidden(element.parentElement); + } + + return false; +} + +/** + * Checks if a DOM element is visible. + * + * @param {Element} element + * The element which is to be checked. + * + * @return {boolean} + */ +function is_visible(element) { + var style = element.ownerGlobal.getComputedStyle(element); + if (style.display == "none") { + return false; + } + if (style.visibility != "visible") { + return false; + } + if (style.display == "-moz-popup" && element.state != "open") { + return false; + } + + // Hiding a parent element will hide all its children + if (element.parentNode != element.ownerDocument && element.parentElement) { + return is_visible(element.parentElement); + } + + return true; +} + +add_setup(async function () { + let tab = tabmail.openTab("contentTab", { + url: "chrome://mochitests/content/browser/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml", + }); + + await BrowserTestUtils.browserLoaded(tab.browser); + tab.browser.focus(); + browser = tab.browser; + searchBar = tab.browser.contentWindow.document.querySelector("search-bar"); +}); + +add_task(async function test_initialState() { + const input = searchBar.shadowRoot.querySelector("input"); + is( + input.getAttribute("aria-label"), + searchBar.getAttribute("label"), + "Label forwarded to aria-label on input" + ); +}); + +add_task(async function test_labelUpdate() { + const input = searchBar.shadowRoot.querySelector("input"); + searchBar.setAttribute("label", "foo"); + await waitForRender(); + is( + input.getAttribute("aria-label"), + "foo", + "Updated label applied to content" + ); +}); + +add_task(async function test_focus() { + const input = searchBar.shadowRoot.querySelector("input"); + searchBar.focus(); + is( + searchBar.shadowRoot.activeElement, + input, + "Input is focused when search bar is focused" + ); +}); + +add_task(async function test_autocompleteEvent() { + const typeAndWaitForAutocomplete = async key => { + const eventPromise = BrowserTestUtils.waitForEvent( + searchBar, + "autocomplete" + ); + await BrowserTestUtils.synthesizeKey(key, {}, browser); + return eventPromise; + }; + searchBar.focus(); + let event = await typeAndWaitForAutocomplete("T"); + is(event.detail, "T", "Autocomplete for T"); + + event = await typeAndWaitForAutocomplete("e"); + is(event.detail, "Te", "Autocomplete for e"); + + event = await typeAndWaitForAutocomplete("KEY_Backspace"); + is(event.detail, "T", "Autocomplete for backspace"); + + await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, browser); +}); + +add_task(async function test_searchEventFromEnter() { + const input = searchBar.shadowRoot.querySelector("input"); + input.value = "Lorem ipsum"; + searchBar.focus(); + + const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search"); + await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser); + const event = await eventPromise; + + is(event.detail, "Lorem ipsum", "Event contains search query"); + await waitForRender(); + is(input.value, "", "Input was cleared"); +}); + +add_task(async function test_searchEventFromButton() { + const input = searchBar.shadowRoot.querySelector("input"); + input.value = "Lorem ipsum"; + + const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search"); + searchBar.shadowRoot.querySelector("button").click(); + const event = await eventPromise; + + is(event.detail, "Lorem ipsum", "Event contains search query"); + await waitForRender(); + is(input.value, "", "Input was cleared"); +}); + +add_task(async function test_searchEventPreventDefault() { + const input = searchBar.shadowRoot.querySelector("input"); + input.value = "Lorem ipsum"; + + searchBar.addEventListener( + "search", + event => { + event.preventDefault(); + }, + { + once: true, + passive: false, + } + ); + + const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search"); + searchBar.shadowRoot.querySelector("button").click(); + await eventPromise; + await waitForRender(); + + is(input.value, "Lorem ipsum"); + + input.value = ""; +}); + +add_task(async function test_placeholderVisibility() { + const placeholder = searchBar.shadowRoot.querySelector("div"); + const input = searchBar.shadowRoot.querySelector("input"); + + input.value = ""; + await waitForRender(); + ok(is_visible(placeholder), "Placeholder is visible initially"); + + input.value = "some input"; + await waitForRender(); + ok(is_hidden(placeholder), "Placeholder is hidden after text is entered"); + + input.value = ""; + await waitForRender(); + ok( + is_visible(placeholder), + "Placeholder is visible again after input is cleared" + ); +}); + +add_task(async function test_placeholderFallbackToLabel() { + const placeholder = searchBar.querySelector("span"); + placeholder.remove(); + + const shadowedPlaceholder = searchBar.shadowRoot.querySelector("div"); + const label = searchBar.getAttribute("label"); + + is( + shadowedPlaceholder.textContent, + label, + "Falls back to label if no placeholder slot contents provided" + ); + + searchBar.setAttribute("label", "Foo bar"); + is( + shadowedPlaceholder.textContent, + "Foo bar", + "Placeholder contents get updated with label attribute" + ); + + searchBar.prepend(placeholder); + searchBar.setAttribute("label", label); +}); + +add_task(async function test_reset() { + const input = searchBar.shadowRoot.querySelector("input"); + const placeholder = searchBar.shadowRoot.querySelector("div"); + input.value = "Lorem ipsum"; + + searchBar.reset(); + + is(input.value, "", "Input empty after reset"); + await waitForRender(); + ok(is_visible(placeholder), "Placeholder visible"); +}); + +add_task(async function test_disabled() { + const input = searchBar.shadowRoot.querySelector("input"); + const button = searchBar.shadowRoot.querySelector("button"); + + ok(!input.disabled, "Input enabled"); + ok(!button.disabled, "Button enabled"); + + searchBar.setAttribute("disabled", true); + + ok(input.disabled, "Disabled propagated to input"); + ok(button.disabled, "Disabled propagated to button"); + + searchBar.removeAttribute("disabled"); + + ok(!input.disabled, "Input enabled again"); + ok(!button.disabled, "Button enabled again"); +}); diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js new file mode 100644 index 0000000000..c2ca1147fd --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js @@ -0,0 +1,99 @@ +/* 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"; + +const { migrateToolbarForSpace } = ChromeUtils.importESModule( + "resource:///modules/ToolbarMigration.sys.mjs" +); +const { getState, storeState } = ChromeUtils.importESModule( + "resource:///modules/CustomizationState.mjs" +); +const { EXTENSION_PREFIX } = ChromeUtils.importESModule( + "resource:///modules/CustomizableItems.sys.mjs" +); +const { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import( + "resource:///modules/ExtensionToolbarButtons.jsm" +); + +const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml"; +const EXTENSION_ID = "thunderbird-compact-light@mozilla.org"; + +add_setup(() => { + storeState({}); +}); + +add_task(async function test_migrate_extension() { + Services.xulStore.setValue(MESSENGER_WINDOW, "mail-bar3", "currentset", ""); + Services.xulStore.setValue( + MESSENGER_WINDOW, + "mail-bar3", + "defaultset", + "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,thunderbird-compact-light_mozilla_org-browserAction-toolbarbutton,button-appmenu" + ); + Services.xulStore.setValue( + MESSENGER_WINDOW, + "mail-bar3", + "extensionset", + "thunderbird-compact-light_mozilla_org-browserAction-toolbarbutton" + ); + const extensionPref = Services.prefs.getStringPref( + "extensions.webextensions.uuids", + "" + ); + const parsedPref = JSON.parse(extensionPref || "{}"); + if (!parsedPref.hasOwnProperty(EXTENSION_ID)) { + parsedPref[EXTENSION_ID] = "foo"; + Services.prefs.setStringPref( + "extensions.webextensions.uuids", + JSON.stringify(parsedPref) + ); + } + + migrateToolbarForSpace("mail"); + + const newState = getState(); + + Assert.deepEqual( + newState.mail, + [ + "get-messages", + "write-message", + "tag-message", + "quick-filter-bar", + "spacer", + `${EXTENSION_PREFIX}${EXTENSION_ID}`, + "spacer", + ], + "Extension button was converted to new ID format" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"), + "Old toolbar default state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "extensionset"), + "Old toolbar extension state is cleared" + ); + Assert.deepEqual( + Object.fromEntries(getCachedAllowedSpaces()), + { [EXTENSION_ID]: ["mail"] }, + "Extension set migrated to new persistent extension state" + ); + + storeState({}); + setCachedAllowedSpaces(new Map()); + if (extensionPref) { + Services.prefs.setStringPref( + "extensions.webextensions.uuids", + extensionPref + ); + } else { + Services.prefs.clearUserPref("extensions.webextensions.uuids"); + } +}); diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js new file mode 100644 index 0000000000..336199ee51 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js @@ -0,0 +1,285 @@ +/* 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/. */ + +let tabmail = document.getElementById("tabmail"); +registerCleanupFunction(() => { + tabmail.closeOtherTabs(tabmail.tabInfo[0]); +}); +let browser; +let testDocument; + +const waitForRender = () => { + return new Promise(resolve => { + window.requestAnimationFrame(resolve); + }); +}; +const getTabButton = tab => tab.shadowRoot.querySelector("button"); +/** + * Get the relevant elements for the tab at the given index. + * + * @param {number} tabIndex + * @returns {{tab: UnifiedToolbarTab, button: HTMLButtonElement, pane: HTMLElement}} + */ +const getTabElements = tabIndex => { + const tab = testDocument.querySelector( + `unified-toolbar-tab:nth-child(${tabIndex})` + ); + const button = getTabButton(tab); + const pane = tab.pane; + return { tab, button, pane }; +}; + +add_setup(async function () { + let tab = tabmail.openTab("contentTab", { + url: "chrome://mochitests/content/browser/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml", + }); + + await BrowserTestUtils.browserLoaded(tab.browser); + tab.browser.focus(); + browser = tab.browser; + testDocument = tab.browser.contentWindow.document; +}); + +add_task(function test_tabElementInitialization() { + const activeTab = testDocument.querySelector("unified-toolbar-tab[selected]"); + is( + activeTab.getAttribute("role"), + "presentation", + "The custom element is just for show" + ); + ok( + !activeTab.hasAttribute("aria-controls"), + "aria-controls removed from custom element" + ); + ok(activeTab.hasAttribute("selected"), "Active tab kept itself selected"); + const tabButton = getTabButton(activeTab); + is(tabButton.getAttribute("role"), "tab", "Active tab is marked as tab"); + is(tabButton.tabIndex, 0, "Active tab is in the focus ring"); + is( + tabButton.getAttribute("aria-selected"), + "true", + "Tab is marked as selected" + ); + ok( + tabButton.hasAttribute("aria-controls"), + "aria-controls got given to button" + ); + + const otherTab = testDocument.querySelector( + "unified-toolbar-tab:not([selected])" + ); + is( + otherTab.getAttribute("role"), + "presentation", + "The custom element is just for show on the other tab" + ); + ok( + !otherTab.hasAttribute("aria-controls"), + "aria-controls removed from the other tab" + ); + ok(!otherTab.hasAttribute("selected"), "Other tab didn't select itself"); + const otherButton = getTabButton(otherTab); + is(otherButton.getAttribute("role"), "tab", "Other tab is marked as tab"); + is(otherButton.tabIndex, -1, "Other tab is not in the focus ring"); + ok( + !otherButton.hasAttribute("aria-selected"), + "Other tab isn't marked as selected" + ); + ok( + otherButton.hasAttribute("aria-controls"), + "aria-controls got given to other button" + ); +}); + +add_task(async function test_paneGetter() { + const tab1 = getTabElements(1); + const tabPane = testDocument.getElementById("tabPane"); + const tab2 = getTabElements(2); + const otherTabPane = testDocument.getElementById("otherTabPane"); + + is( + tab1.button.getAttribute("aria-controls"), + tabPane.id, + "Tab 1 controls tab 1 pane" + ); + is( + tab2.button.getAttribute("aria-controls"), + otherTabPane.id, + "Tab 2 controls tab 2 pane" + ); + + Assert.strictEqual( + tab1.tab.pane, + tabPane, + "Tab 1 pane getter returns #tabPane" + ); + Assert.strictEqual( + tab2.tab.pane, + otherTabPane, + "Tab 2 pane getter returns #otherTabPane" + ); +}); + +add_task(async function test_unselect() { + const tab = getTabElements(1); + + tab.tab.unselect(); + + ok(!tab.button.hasAttribute("aria-selected"), "Tab not marked as selected"); + is(tab.button.tabIndex, -1, "Tab not in focus ring"); + ok(!tab.tab.hasAttribute("selected"), "Tab not marked selected"); + ok(tab.pane.hidden, "Tab pane hidden"); +}); + +add_task(async function test_select() { + const tab1 = getTabElements(1); + const tab2 = getTabElements(2); + + let tabswitchPromise = BrowserTestUtils.waitForEvent( + testDocument.body, + "tabswitch" + ); + tab1.tab.select(); + + await tabswitchPromise; + ok(tab1.tab.hasAttribute("selected"), "Tab 1 selected"); + is( + tab1.button.getAttribute("aria-selected"), + "true", + "Tab 1 marked as selected" + ); + is(tab1.button.tabIndex, 0, "Tab 1 keyboard selectable"); + ok(!tab1.pane.hidden, "Tab pane for tab 1 visible"); + + tabswitchPromise = BrowserTestUtils.waitForEvent(tab2.tab, "tabswitch"); + tab2.tab.select(); + + await tabswitchPromise; + ok(tab2.tab.hasAttribute("selected"), "Tab 2 selected"); + is( + tab2.button.getAttribute("aria-selected"), + "true", + "Tab 2 has a11y selection" + ); + is(tab2.button.tabIndex, 0, "Tab 2 keyboard selectable"); + ok(!tab2.pane.hidden, "Tab pane for tab 2 visible"); + + ok(!tab1.tab.hasAttribute("selected"), "Tab 1 unselected"); + ok(!tab1.button.hasAttribute("aria-selected"), "Tab 1 marked as unselected"); + is(tab1.button.tabIndex, -1, "Tab 1 not in focus ring"); + ok(tab1.pane.hidden, "Tab pane for tab 1 hidden"); +}); + +add_task(async function test_switchingTabWithMouse() { + const tab1 = getTabElements(1); + const tab2 = getTabElements(2); + + tab2.button.click(); + ok(tab2.tab.hasAttribute("selected"), "Other tab is selected"); + is(tab2.button.tabIndex, 0, "Other tab is in focus ring"); + ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected"); + is(tab1.button.tabIndex, -1, "First tab is not in focus ring"); + ok( + BrowserTestUtils.is_visible(tab2.pane), + "Tab pane for selected tab is visible" + ); + ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden"); + + tab1.button.click(); + ok(tab1.tab.hasAttribute("selected"), "First tab is selected"); + is(tab1.button.tabIndex, 0, "First tab is in focus ring"); + ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected"); + is(tab2.button.tabIndex, -1, "Other tab is not in focus ring"); + ok( + BrowserTestUtils.is_visible(tab1.pane), + "Tab pane for first tab is visible" + ); + ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden"); +}); + +add_task(async function test_switchingTabWithKeyboard() { + const tab1 = getTabElements(1); + const tab2 = getTabElements(2); + + tab1.tab.focus(); + is(testDocument.activeElement, tab1.tab, "Initially first tab is active"); + + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, browser); + is(testDocument.activeElement, tab2.tab, "Second tab is focused"); + is( + tab2.tab.shadowRoot.activeElement, + tab2.button, + "Button within tab is focused" + ); + await BrowserTestUtils.synthesizeKey(" ", {}, browser); + ok(tab2.tab.hasAttribute("selected"), "Other tab is selected"); + is(tab2.button.tabIndex, 0, "Other tab is in focus ring"); + ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected"); + is(tab1.button.tabIndex, -1, "First tab is not in focus ring"); + ok( + BrowserTestUtils.is_visible(tab2.pane), + "Tab pane for selected tab is visible" + ); + ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden"); + + await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, browser); + is(testDocument.activeElement, tab1.tab, "Previous tab is selected"); + await BrowserTestUtils.synthesizeKey("KEY_End", {}, browser); + is(testDocument.activeElement, tab2.tab, "Last tab is selected"); + await BrowserTestUtils.synthesizeKey("KEY_Home", {}, browser); + is(testDocument.activeElement, tab1.tab, "First tab is selected"); + await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser); + ok(tab1.tab.hasAttribute("selected"), "First tab is selected"); + is(tab1.button.tabIndex, 0, "First tab is in focus ring"); + ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected"); + is(tab2.button.tabIndex, -1, "Other tab is not in focus ring"); + ok( + BrowserTestUtils.is_visible(tab1.pane), + "Tab pane for first tab is visible" + ); + ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden"); +}); + +add_task(async function test_switchingTabWithKeyboardRTL() { + testDocument.dir = "rtl"; + await waitForRender(); + const tab1 = getTabElements(1); + const tab2 = getTabElements(2); + + tab1.tab.focus(); + is(testDocument.activeElement, tab1.tab, "Initially first tab is active"); + + await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, browser); + is(testDocument.activeElement, tab2.tab, "Second tab is selected"); + is( + tab2.tab.shadowRoot.activeElement, + tab2.button, + "Button within tab is focused" + ); + await BrowserTestUtils.synthesizeKey(" ", {}, browser); + ok(tab2.tab.hasAttribute("selected"), "Other tab is selected"); + is(tab2.button.tabIndex, 0, "Other tab is in focus ring"); + ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected"); + is(tab1.button.tabIndex, -1, "First tab is not in focus ring"); + ok( + BrowserTestUtils.is_visible(tab2.pane), + "Tab pane for selected tab is visible" + ); + ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden"); + + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, browser); + is(testDocument.activeElement, tab1.tab, "Previous tab is selected"); + await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser); + ok(tab1.tab.hasAttribute("selected"), "First tab is selected"); + is(tab1.button.tabIndex, 0, "First tab is in focus ring"); + ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected"); + is(tab2.button.tabIndex, -1, "Other tab is not in focus ring"); + ok( + BrowserTestUtils.is_visible(tab1.pane), + "Tab pane for first tab is visible" + ); + ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden"); + + testDocument.dir = "ltr"; +}); diff --git a/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml b/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml new file mode 100644 index 0000000000..33000135b4 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta charset="utf-8" /> + <title>Search bar element test</title> + <script type="module" src="chrome://messenger/content/unifiedtoolbar/search-bar.mjs"></script> + </head> + <body> + <template id="searchBarTemplate"> + <form> + <input type="search" placeholder="" required="required"/> + <div aria-hidden="true"><slot name="placeholder"></slot></div> + <button class="button button-flat icon-button"><slot name="button"></slot></button> + </form> + </template> + <search-bar label="Search"> + <span slot="placeholder">Placeholder</span> + <img slot="button" src="chrome://messenger/skin/icons/new/compact/search.svg" alt="Search"/> + </search-bar> + </body> +</html> diff --git a/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml b/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml new file mode 100644 index 0000000000..f30f2b7d8b --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr"> + <head> + <meta charset="utf-8" /> + <title>Search bar element test</title> + <script type="module" src="chrome://messenger/content/unifiedtoolbar/unified-toolbar-tab.mjs"></script> + </head> + <body> + <template id="unifiedToolbarTabTemplate"> + <button role="tab"> + <img alt="" src="" /> + <slot></slot> + </button> + </template> + <div role="tablist"> + <unified-toolbar-tab selected="true" aria-controls="tabPane">Tab Title</unified-toolbar-tab> + <unified-toolbar-tab aria-controls="otherTabPane">Other Tab</unified-toolbar-tab> + </div> + <div id="tabPane" role="tabpanel">Panel 1</div> + <div id="otherTabPane" role="tabpanel" hidden="hidden">Panel 2</div> + </body> +</html> diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js b/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js new file mode 100644 index 0000000000..0ffeefa00b --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js @@ -0,0 +1,40 @@ +/* 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/. */ + +const { BUTTON_STYLE_MAP, BUTTON_STYLE_PREF } = ChromeUtils.importESModule( + "resource:///modules/ButtonStyle.mjs" +); + +add_task(function test_buttonStyleMap() { + Assert.ok(Array.isArray(BUTTON_STYLE_MAP), "BUTTON_STYLE_MAP is an array"); + Assert.ok( + BUTTON_STYLE_MAP.every(style => typeof style === "string"), + "All entries in the style map should be strings" + ); + for (const style of BUTTON_STYLE_MAP) { + Assert.stringMatches( + style, + /[a-z-]/, + "Button style class should be formatted in kebab case" + ); + } +}); + +add_task(function test_buttonStylePref() { + Assert.equal( + typeof BUTTON_STYLE_PREF, + "string", + "BUTTON_STYLE_PREF is a string" + ); + const prefValue = Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0); + Assert.ok( + Number.isInteger(prefValue), + "BUTTON_STYLE_PREF pref should hold an integer" + ); + Assert.less( + prefValue, + BUTTON_STYLE_MAP.length, + "Value of BUTTON_STYLE_PREF should be within map" + ); +}); diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js new file mode 100644 index 0000000000..55d4f5ba91 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js @@ -0,0 +1,123 @@ +/* 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/. */ + +const { + getAvailableItemIdsForSpace, + getDefaultItemIdsForSpace, + MULTIPLE_ALLOWED_ITEM_IDS, + SKIP_FOCUS_ITEM_IDS, +} = ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs"); + +const { default: CUSTOMIZABLE_ITEMS } = ChromeUtils.importESModule( + "resource:///modules/CustomizableItemsDetails.mjs" +); + +add_task(function test_getAvailableItemIdsForSpace_anySpace() { + const itemsForAnySpace = getAvailableItemIdsForSpace(); + Assert.ok(Array.isArray(itemsForAnySpace), "returns an array"); + for (const itemId of itemsForAnySpace) { + Assert.equal(typeof itemId, "string", `item ID "${itemId}" is string`); + Assert.greater(itemId.length, 0, `item ID is not empty`); + } +}); + +add_task(function test_getAvailableItemIdsForSpace_emptySpace() { + const itemsForEmptySpace = getAvailableItemIdsForSpace("test"); + Assert.deepEqual(itemsForEmptySpace, [], "Empty array for empty space"); +}); + +add_task(function test_getAvailableItemIdsForSpace_includingAgnostic() { + const items = getAvailableItemIdsForSpace("mail", true); + const itemsForAnySpace = getAvailableItemIdsForSpace(); + const itemsForMailSpace = getAvailableItemIdsForSpace("mail"); + + Assert.ok( + itemsForAnySpace.every(itemId => items.includes(itemId)), + "All space agnostic items are included" + ); + + Assert.ok( + itemsForMailSpace.every(itemId => items.includes(itemId)), + "All mail space items are included" + ); +}); + +add_task(function test_getDefaultItemIdsForSpace_default() { + const items = getDefaultItemIdsForSpace("default"); + + Assert.ok(Array.isArray(items), "Should return an array"); + Assert.deepEqual( + items, + ["spacer", "search-bar", "spacer"], + "Default space should contain the default item set" + ); +}); + +add_task(function test_getDefaultItemIdsForSpace_cloningArray() { + const items1 = getDefaultItemIdsForSpace("default"); + const items2 = getDefaultItemIdsForSpace("default"); + const items3 = getDefaultItemIdsForSpace("mail"); + + Assert.notStrictEqual( + items1, + items2, + "The default sets should be different array instances" + ); + Assert.notStrictEqual( + items2, + items3, + "The second default set an mail space should be different array instances" + ); + Assert.notStrictEqual( + items3, + items1, + "The mail space and first default set should be different array instances" + ); + + Assert.deepEqual( + items1, + items2, + "The two default pseudospace sets should contain the same items" + ); +}); + +add_task(function test_multipleAllowedItemIds() { + Assert.equal( + typeof MULTIPLE_ALLOWED_ITEM_IDS.has, + "function", + "Multiple allowed item IDs should be set-like" + ); + Assert.ok( + Array.from(MULTIPLE_ALLOWED_ITEM_IDS).every( + itemId => typeof itemId === "string" + ), + "Every item in the set should be a string" + ); + for (const item of CUSTOMIZABLE_ITEMS) { + Assert.equal( + MULTIPLE_ALLOWED_ITEM_IDS.has(item.id), + Boolean(item.allowMultiple), + `Set's state should matche the allowMultiple value of ${item.allowMultiple} for ${item.id}` + ); + } +}); + +add_task(function test_skipFocusItemIds() { + Assert.equal( + typeof SKIP_FOCUS_ITEM_IDS.has, + "function", + "Skip focus item IDs should be set-like" + ); + Assert.ok( + Array.from(SKIP_FOCUS_ITEM_IDS).every(itemId => typeof itemId === "string"), + "Every item in the set should be a string" + ); + for (const item of CUSTOMIZABLE_ITEMS) { + Assert.equal( + SKIP_FOCUS_ITEM_IDS.has(item.id), + Boolean(item.skipFocus), + `Set's state should match the skipFocus value of ${item.skipFocus} for ${item.id}` + ); + } +}); diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js new file mode 100644 index 0000000000..474e5483ce --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js @@ -0,0 +1,103 @@ +/* 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/. */ + +const { default: CUSTOMIZABLE_ITEMS } = ChromeUtils.importESModule( + "resource:///modules/CustomizableItemsDetails.mjs" +); + +add_task(function test_format() { + for (const item of CUSTOMIZABLE_ITEMS) { + Assert.equal(typeof item, "object", "Customizable item is an object"); + Assert.equal(typeof item.id, "string", `id "${item.id}" is a string`); + Assert.ok(!item.id.includes(","), `id "${item.id}" may not contain commas`); + Assert.greater(item.id.length, 0, `id "${item.id}" is not empty`); + Assert.equal( + typeof item.labelId, + "string", + `labelId is a string for ${item.id}` + ); + Assert.greater( + item.labelId.length, + 0, + `labelId is not empty for ${item.id}` + ); + Assert.ok( + !item.allowMultiple || item.allowMultiple === true, + `allowMultiple is falsy or boolean for ${item.id}` + ); + Assert.ok( + item.spaces === undefined || Array.isArray(item.spaces), + `spaces is undefined or an array for ${item.id}` + ); + if (item.spaces) { + for (const space of item.spaces) { + Assert.equal( + typeof space, + "string", + `space "${space}" expected to be string for ${item.id}` + ); + Assert.greater( + space.length, + 0, + `space is not empty in ${item.id} spaces` + ); + } + } + Assert.ok( + item.templateId === undefined || typeof item.templateId === "string", + `templateId must be undefined or a string for ${item.id}` + ); + if (item.templateId !== undefined) { + Assert.greater( + item.templateId.length, + 0, + `templateId is not empty for ${item.id}` + ); + Assert.ok( + item.requiredModules === undefined || + Array.isArray(item.requiredModules), + `requiredModules is undefined or an array for ${item.id}` + ); + if (item.requiredModules) { + for (const module of item.requiredModules) { + Assert.equal( + typeof module, + "string", + `module "${module}" expected to be string for ${item.id}` + ); + Assert.greater( + module.length, + 0, + `module is not empty in ${item.id} requiredModules` + ); + } + } + } else { + Assert.strictEqual( + item.requiredModules, + undefined, + `requiredModules must not be set because there is no template for item ${item.id}` + ); + } + Assert.ok( + item.hasContextMenu === undefined || + typeof item.hasContextMenu === "boolean", + `hasContextMenu must be undefined or a boolean for ${item.id}` + ); + Assert.ok( + item.skipFocus === undefined || typeof item.skipFocus === "boolean", + `skipFocus must be undefined or a boolean for ${item.id}` + ); + } +}); + +add_task(function test_idsUnique() { + const allIds = CUSTOMIZABLE_ITEMS.map(item => item.id); + const idCounts = allIds.reduce((counts, id) => { + counts[id] = counts[id] ? counts[id] + 1 : 1; + return counts; + }, {}); + const duplicateIds = Object.keys(idCounts).filter(id => idCounts[id] > 1); + Assert.deepEqual(duplicateIds, [], "All IDs should only be used once"); +}); diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js new file mode 100644 index 0000000000..048b5c5cde --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js @@ -0,0 +1,64 @@ +/* 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/. */ + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +const { storeState, getState } = ChromeUtils.importESModule( + "resource:///modules/CustomizationState.mjs" +); + +add_setup(function () { + // Ensure xulStore has a profile to refer to. + do_get_profile(); +}); + +add_task(function test_getState_empty() { + const state = getState(); + Assert.equal(typeof state, "object", "State should be an object"); + Assert.deepEqual(state, {}, "Empty state should be an empty object"); +}); + +add_task(async function test_storeState_observer() { + const stateChangeObserved = TestUtils.topicObserved( + "unified-toolbar-state-change" + ); + storeState({ + mail: ["write-message", "spacer", "search-bar", "spacer"], + }); + await stateChangeObserved; +}); + +add_task(function test_storeState_getState() { + const state = { + mail: ["write-message", "spacer", "search-bar", "spacer"], + calendar: [], + }; + const previousState = getState(); + Assert.notDeepEqual( + previousState, + state, + "Current state should be different from the state to write" + ); + storeState(state); + const newState = getState(); + Assert.deepEqual( + newState, + state, + "State loaded should matche the stored state" + ); + Assert.notStrictEqual( + newState, + state, + "State loaded should not be the same object as what was saved" + ); +}); + +registerCleanupFunction(() => { + Services.xulStore.removeValue( + "chrome://messenger/content/messenger.xhtml", + "unifiedToolbar", + "state" + ); +}); diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js b/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js new file mode 100644 index 0000000000..637d40e066 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js @@ -0,0 +1,431 @@ +/* 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"; + +const { migrateToolbarForSpace, clearXULToolbarState } = + ChromeUtils.importESModule("resource:///modules/ToolbarMigration.sys.mjs"); +const { getState, storeState } = ChromeUtils.importESModule( + "resource:///modules/CustomizationState.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml"; + +function setXULToolbarState( + currentSet = "", + defaultSet = "", + toolbarId = "mail-bar3" +) { + Services.xulStore.setValue( + MESSENGER_WINDOW, + toolbarId, + "currentset", + currentSet + ); + Services.xulStore.setValue( + MESSENGER_WINDOW, + toolbarId, + "defaultset", + defaultSet + ); +} + +add_setup(() => { + do_get_profile(); + storeState({}); +}); + +add_task(function test_migration_customized() { + setXULToolbarState( + "button-getmsg,button-newmsg,button-reply,spacer,qfb-show-filter-bar,button-file,folder-location-container,spring,gloda-search,button-appmenu" + ); + setXULToolbarState( + "menubar-items,spring,button-addons", + "", + "toolbar-menubar" + ); + setXULToolbarState("button-delete", "", "tabbar-toolbar"); + + migrateToolbarForSpace("mail"); + + const newState = getState(); + + Assert.deepEqual( + newState.mail, + [ + "get-messages", + "write-message", + "reply", + "spacer", + "quick-filter-bar", + "move-to", + "folder-location", + "spacer", + "search-bar", + "spacer", + "add-ons-and-themes", + "delete", + ], + "Items were combined and migrated" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_migration_defaults() { + setXULToolbarState(); + setXULToolbarState("", "", "toolbar-menubar"); + setXULToolbarState("", "", "tabbar-toolbar"); + + migrateToolbarForSpace("mail"); + + const newState = getState(); + + Assert.ok(!newState.mail, "New default state was preserved"); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_migration_empty() { + setXULToolbarState("__empty"); + setXULToolbarState("__empty", "menubar-items,spring", "toolbar-menubar"); + setXULToolbarState("__empty", "", "tabbar-toolbar"); + + migrateToolbarForSpace("mail"); + + const newState = getState(); + + Assert.deepEqual(newState.mail, [], "The toolbar contents were emptied"); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_migration_noop() { + const state = { mail: ["spacer", "search-bar", "spacer"] }; + storeState(state); + + migrateToolbarForSpace("mail"); + + const newState = getState(); + + Assert.deepEqual(newState, state, "Customization state is not modified"); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_calendar_migration() { + setXULToolbarState( + "calendar-synchronize-button,calendar-newevent-button,separator,calendar-edit-button,calendar-delete-button,spring,calendar-unifinder-button,calendar-appmenu-button", + "", + "calendar-toolbar2" + ); + setXULToolbarState( + "menubar-items,spring,button-addons", + "", + "toolbar-menubar" + ); + setXULToolbarState("button-delete", "", "tabbar-toolbar"); + + migrateToolbarForSpace("calendar"); + + const newState = getState(); + + Assert.deepEqual( + newState.calendar, + [ + "synchronize", + "new-event", + "edit-event", + "delete-event", + "spacer", + "unifinder", + "spacer", + "add-ons-and-themes", + ], + "Items were combined and migrated" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "calendar-toolbar2", + "currentset" + ), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "calendar-toolbar2", + "defaultset" + ), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_calendar_migration_defaults() { + setXULToolbarState("", "", "calendar-toolbar2"); + setXULToolbarState("", "", "toolbar-menubar"); + setXULToolbarState("", "", "tabbar-toolbar"); + + migrateToolbarForSpace("calendar"); + + const newState = getState(); + + Assert.deepEqual( + newState.calendar, + [ + "synchronize", + "new-event", + "new-task", + "edit-event", + "delete-event", + "spacer", + ], + "Default states were combined and migrated" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "calendar-toolbar2", + "currentset" + ), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "calendar-toolbar2", + "defaultset" + ), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_tasks_migration() { + setXULToolbarState( + "task-synchronize-button,task-newtask-button,task-edit-button,task-delete-button,task-print-button,spring,task-appmenu-button", + "", + "task-toolbar2" + ); + setXULToolbarState( + "menubar-items,spring,button-addons", + "", + "toolbar-menubar" + ); + setXULToolbarState("button-delete", "", "tabbar-toolbar"); + + migrateToolbarForSpace("tasks"); + + const newState = getState(); + + Assert.deepEqual( + newState.tasks, + [ + "synchronize", + "new-task", + "edit-event", + "delete-event", + "print-event", + "spacer", + "add-ons-and-themes", + ], + "Items were combined and migrated" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "task-toolbar2", + "currentset" + ), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "task-toolbar2", + "defaultset" + ), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_tasks_migration_defaults() { + setXULToolbarState("", "", "task-toolbar2"); + setXULToolbarState("", "", "toolbar-menubar"); + setXULToolbarState("", "", "tabbar-toolbar"); + + migrateToolbarForSpace("tasks"); + + const newState = getState(); + + Assert.deepEqual( + newState.tasks, + [ + "synchronize", + "new-event", + "new-task", + "edit-event", + "delete-event", + "spacer", + ], + "Default states were combined and migrated" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "task-toolbar2", + "currentset" + ), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "task-toolbar2", + "defaultset" + ), + "Old toolbar default state is cleared" + ); + + storeState({}); +}); + +add_task(function test_global_items_migration() { + setXULToolbarState( + "menubar-items,spring,button-addons", + "", + "toolbar-menubar" + ); + setXULToolbarState("button-delete", "", "tabbar-toolbar"); + + migrateToolbarForSpace("settings"); + + const newState = getState(); + + Assert.deepEqual(newState.settings, [ + "spacer", + "search-bar", + "spacer", + "add-ons-and-themes", + ]); + + storeState({}); +}); + +add_task(function test_global_items_migration_defaults() { + setXULToolbarState("", "", "toolbar-menubar"); + setXULToolbarState("", "", "tabbar-toolbar"); + + migrateToolbarForSpace("settings"); + + const newState = getState(); + + Assert.deepEqual(newState.settings, ["spacer", "search-bar", "spacer"]); + + storeState({}); +}); + +add_task(function test_clear_xul_toolbar_state() { + setXULToolbarState( + "menubar-items,spring,button-addons", + "menubar-items,spring", + "toolbar-menubar" + ); + + clearXULToolbarState("toolbar-menubar"); + + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "toolbar-menubar", + "currentset" + ), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue( + MESSENGER_WINDOW, + "toolbar-menubar", + "defaultset" + ), + "Old toolbar default state is cleared" + ); +}); + +add_task(function test_migration_defaults_with_extension() { + setXULToolbarState( + AppConstants.platform == "macosx" + ? "button-getmsg,button-newmsg,button-tag,qfb-show-filter-bar,spring,gloda-search,extension1,extension2,button-appmenu" + : "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,extension1,extension2,button-appmenu" + ); + setXULToolbarState("", "", "toolbar-menubar"); + setXULToolbarState("", "", "tabbar-toolbar"); + Services.xulStore.setValue( + MESSENGER_WINDOW, + "mail-bar3", + "extensionset", + "extension1,extension2" + ); + + migrateToolbarForSpace("mail"); + + const newState = getState(); + + Assert.ok(!newState.mail, "New default state was preserved"); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"), + "Old toolbar state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"), + "Old toolbar default state is cleared" + ); + Assert.ok( + !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "extensionset"), + "Old toolbar extension state is cleared" + ); + + storeState({}); +}); diff --git a/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini b/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini new file mode 100644 index 0000000000..ec9807b399 --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini @@ -0,0 +1,8 @@ +[DEFAULT] +head = + +[test_buttonStyle.js] +[test_customizableItems.js] +[test_customizableItemsDetails.js] +[test_customizationState.js] +[test_toolbarMigration.js] |