summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/unifiedtoolbar
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/unifiedtoolbar')
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customizable-element.mjs299
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customization-palette.mjs243
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customization-target.mjs333
-rw-r--r--comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs148
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs38
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs19
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs40
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs44
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs81
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs223
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs183
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs32
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs15
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/space-button.mjs41
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs40
-rw-r--r--comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs549
-rw-r--r--comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs153
-rw-r--r--comm/mail/components/unifiedtoolbar/content/search-bar.mjs121
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs240
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs264
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs414
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs119
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs540
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml366
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml133
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml137
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css53
-rw-r--r--comm/mail/components/unifiedtoolbar/jar.mn29
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs23
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs134
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs445
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs55
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs419
-rw-r--r--comm/mail/components/unifiedtoolbar/moz.build22
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser.ini16
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js173
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js263
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js99
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js285
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml21
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml22
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js40
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js123
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js103
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js64
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js431
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini8
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]