/* 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"; // This is loaded into chrome windows with the subscript loader. Wrap in // a block to prevent accidentally leaking globals onto `window`. { const { TabMetrics } = ChromeUtils.importESModule( "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs" ); class MozTabbrowserTabGroup extends MozXULElement { static markup = ` `; /** @type {string} */ #label; /** @type {MozTextLabel} */ #labelElement; /** @type {string} */ #colorCode; /** @type {MutationObserver} */ #tabChangeObserver; /** @type {boolean} */ #wasCreatedByAdoption = false; constructor() { super(); } static get inheritedAttributes() { return { ".tab-group-label": "text=label,tooltiptext=data-tooltip", }; } connectedCallback() { // Always set the mutation observer to listen for tab change events, even // if we are already initialized. // This is needed to ensure events continue to fire even if the tab group is // moved from the horizontal to vertical tab layout or vice-versa, which // causes the component to be repositioned in the DOM. this.#observeTabChanges(); if (this._initialized) { return; } this._initialized = true; this.saveOnWindowClose = true; this.textContent = ""; this.appendChild(this.constructor.fragment); this.initializeAttributeInheritance(); this.#labelElement = this.querySelector(".tab-group-label"); // Mirroring MozTabbrowserTab this.#labelElement.container = gBrowser.tabContainer; this.#labelElement.group = this; this.#labelElement.addEventListener("click", this); this.#labelElement.addEventListener("contextmenu", e => { e.preventDefault(); gBrowser.tabGroupMenu.openEditModal(this); return false; }); this.#updateLabelAriaAttributes(); this.#updateCollapsedAriaAttributes(); this.addEventListener("TabSelect", this); let tabGroupCreateDetail = this.#wasCreatedByAdoption ? { isAdoptingGroup: true } : {}; this.dispatchEvent( new CustomEvent("TabGroupCreate", { bubbles: true, detail: tabGroupCreateDetail, }) ); // Reset `wasCreatedByAdoption` to default of false so that we only // claim that a tab group was created by adoption the first time it // mounts after getting created by `Tabbrowser.adoptTabGroup`. this.#wasCreatedByAdoption = false; } disconnectedCallback() { this.#tabChangeObserver?.disconnect(); } #observeTabChanges() { if (!this.#tabChangeObserver) { this.#tabChangeObserver = new window.MutationObserver(mutationList => { for (let mutation of mutationList) { // TabGrouped and TabUngrouped events are triggered on the tab // group, not the tab itself. This is a bit unorthodox, but fixes // bug1964152 where tab group events are not fired correctly when // tabs change windows (because the tab is detached from the DOM at // time of the event). mutation.addedNodes.forEach(node => { if (node.tagName === "tab") { this.dispatchEvent( new CustomEvent("TabGrouped", { bubbles: true, detail: node, }) ); node.setAttribute("aria-level", 2); } }); mutation.removedNodes.forEach(node => { if (node.tagName === "tab") { this.dispatchEvent( new CustomEvent("TabUngrouped", { bubbles: true, detail: node, }) ); // Tab could have moved to be ungrouped (level 1) // or to a different group (level 2). node.setAttribute("aria-level", node.group ? 2 : 1); // `posinset` and `setsize` only need to be set explicitly // on grouped tabs so that a11y tools can tell users that a // given tab is "2 of 7" in the group, for example. node.removeAttribute("aria-posinset"); node.removeAttribute("aria-setsize"); } }); } if (!this.tabs.length) { this.dispatchEvent( new CustomEvent("TabGroupRemoved", { bubbles: true }) ); this.remove(); Services.obs.notifyObservers( this, "browser-tabgroup-removed-from-dom" ); } else { // Renumber tabs so that a11y tools can tell users that a given // tab is "2 of 7" in the group, for example. let tabs = this.tabs; let tabCount = tabs.length; tabs.forEach((tab, index) => { tab.setAttribute("aria-posinset", index + 1); tab.setAttribute("aria-setsize", tabCount); }); } }); } this.#tabChangeObserver.observe(this, { childList: true }); } get color() { return this.#colorCode; } set color(code) { let diff = code !== this.#colorCode; this.#colorCode = code; this.style.setProperty( "--tab-group-color", `var(--tab-group-color-${code})` ); this.style.setProperty( "--tab-group-color-invert", `var(--tab-group-color-${code}-invert)` ); this.style.setProperty( "--tab-group-color-pale", `var(--tab-group-color-${code}-pale)` ); if (diff) { this.dispatchEvent( new CustomEvent("TabGroupUpdate", { bubbles: true }) ); } } get id() { return this.getAttribute("id"); } set id(val) { this.setAttribute("id", val); } get label() { return this.#label; } set label(val) { let diff = val !== this.#label; this.#label = val; // If the group name is empty, use a zero width space so we // always create a text node and get consistent layout. this.setAttribute("label", val || "\u200b"); this.dataset.tooltip = val; this.#updateLabelAriaAttributes(); if (diff) { this.dispatchEvent( new CustomEvent("TabGroupUpdate", { bubbles: true }) ); } } // alias for label get name() { return this.label; } set name(newName) { this.label = newName; } get collapsed() { return this.hasAttribute("collapsed"); } set collapsed(val) { if (!!val == this.collapsed) { return; } if (val) { for (let tab of this.tabs) { // Unlock tab sizes. tab.style.maxWidth = ""; } } this.toggleAttribute("collapsed", val); this.#updateCollapsedAriaAttributes(); const eventName = val ? "TabGroupCollapse" : "TabGroupExpand"; this.dispatchEvent(new CustomEvent(eventName, { bubbles: true })); } #lastAddedTo = 0; get lastSeenActive() { return Math.max( this.#lastAddedTo, ...this.tabs.map(t => t.lastSeenActive) ); } async #updateLabelAriaAttributes() { let tabGroupName = this.#label; if (!tabGroupName) { tabGroupName = await gBrowser.tabLocalization.formatValue( "tab-group-name-default" ); } let tabGroupDescription = await gBrowser.tabLocalization.formatValue( "tab-group-description", { tabGroupName, } ); this.#labelElement?.setAttribute("aria-label", tabGroupName); this.#labelElement?.setAttribute("aria-description", tabGroupDescription); this.#labelElement?.setAttribute("aria-level", 1); } #updateCollapsedAriaAttributes() { const ariaExpanded = this.collapsed ? "false" : "true"; this.#labelElement?.setAttribute("aria-expanded", ariaExpanded); } get tabs() { return Array.from(this.children).filter(node => node.matches("tab")); } /** * @returns {MozTextLabel} */ get labelElement() { return this.#labelElement; } /** * @param {boolean} value */ set wasCreatedByAdoption(value) { this.#wasCreatedByAdoption = value; } /** * add tabs to the group * * @param {MozTabbrowserTab[]} tabs * @param {TabMetricsContext} [metricsContext] * Optional context to record for metrics purposes. */ addTabs(tabs, metricsContext) { for (let tab of tabs) { if (tab.pinned) { tab.ownerGlobal.gBrowser.unpinTab(tab); } let tabToMove = this.ownerGlobal === tab.ownerGlobal ? tab : gBrowser.adoptTab(tab, { tabIndex: gBrowser.tabs.at(-1)._tPos + 1, selectTab: tab.selected, }); gBrowser.moveTabToGroup(tabToMove, this, metricsContext); } this.#lastAddedTo = Date.now(); } /** * Remove all tabs from the group and delete the group. * @param {TabMetricsContext} [metricsContext] */ ungroupTabs( metricsContext = { isUserTriggered: false, telemetrySource: TabMetrics.METRIC_SOURCE.UNKNOWN, } ) { this.dispatchEvent( new CustomEvent("TabGroupUngroup", { bubbles: true, detail: metricsContext, }) ); for (let i = this.tabs.length - 1; i >= 0; i--) { gBrowser.ungroupTab(this.tabs[i]); } } /** * Save group data to session store. * * @param {object} [options] * @param {boolean} [options.isUserTriggered] * Whether or not the save operation was explicitly called by the user. * Used for telemetry. Default is false. */ save({ isUserTriggered = false } = {}) { SessionStore.addSavedTabGroup(this); this.dispatchEvent( new CustomEvent("TabGroupSaved", { bubbles: true, detail: { isUserTriggered }, }) ); } saveAndClose({ isUserTriggered } = {}) { this.save({ isUserTriggered }); gBrowser.removeTabGroup(this); } /** * @param {PointerEvent} event */ on_click(event) { if (event.target === this.#labelElement && event.button === 0) { event.preventDefault(); this.collapsed = !this.collapsed; gBrowser.tabGroupMenu.close(); /** @type {GleanCounter} */ let interactionMetric = this.collapsed ? Glean.tabgroup.groupInteractions.collapse : Glean.tabgroup.groupInteractions.expand; interactionMetric.add(1); } } on_TabSelect() { this.collapsed = false; } /** * If one of this group's tabs is the selected tab, this will do nothing. * Otherwise, it will expand the group if collapsed, and select the first * tab in its list. */ select() { this.collapsed = false; if (gBrowser.selectedTab.group == this) { return; } gBrowser.selectedTab = this.tabs[0]; } } customElements.define("tab-group", MozTabbrowserTabGroup); }