summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs')
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs540
1 files changed, 540 insertions, 0 deletions
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);