tabgroup.js (20774B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 // This is loaded into chrome windows with the subscript loader. Wrap in 8 // a block to prevent accidentally leaking globals onto `window`. 9 { 10 const { TabMetrics } = ChromeUtils.importESModule( 11 "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs" 12 ); 13 14 class MozTabbrowserTabGroup extends MozXULElement { 15 static markup = ` 16 <vbox class="tab-group-label-container" pack="center"> 17 <vbox class="tab-group-label-hover-highlight" pack="center"> 18 <label class="tab-group-label" role="button" /> 19 </vbox> 20 </vbox> 21 <html:slot/> 22 <vbox class="tab-group-overflow-count-container" pack="center"> 23 <label class="tab-group-overflow-count" role="button" /> 24 </vbox> 25 `; 26 27 /** @type {string} */ 28 #defaultGroupName = ""; 29 30 /** @type {string} */ 31 #label; 32 33 /** @type {MozTextLabel} */ 34 #labelElement; 35 36 /** @type {MozXULElement} */ 37 #labelContainerElement; 38 39 /** @type {MozTextLabel} */ 40 #overflowCountLabel; 41 42 /** @type {MozXULElement} */ 43 overflowContainer; 44 45 /** @type {string} */ 46 #colorCode; 47 48 /** @type {MutationObserver} */ 49 #tabChangeObserver; 50 51 /** @type {boolean} */ 52 #wasCreatedByAdoption = false; 53 54 constructor() { 55 super(); 56 57 XPCOMUtils.defineLazyPreferenceGetter( 58 this, 59 "_showTabGroupHoverPreview", 60 "browser.tabs.groups.hoverPreview.enabled", 61 false 62 ); 63 } 64 65 static get inheritedAttributes() { 66 return { 67 ".tab-group-label": "text=label,tooltiptext=data-tooltip", 68 }; 69 } 70 71 connectedCallback() { 72 // Always set the mutation observer to listen for tab change events, even 73 // if we are already initialized. 74 // This is needed to ensure events continue to fire even if the tab group is 75 // moved from the horizontal to vertical tab layout or vice-versa, which 76 // causes the component to be repositioned in the DOM. 77 this.#observeTabChanges(); 78 79 // Similar to above, always set up TabSelect listener, as this gets 80 // removed in disconnectedCallback 81 this.ownerGlobal.addEventListener("TabSelect", this); 82 83 if (this._initialized) { 84 return; 85 } 86 87 this._initialized = true; 88 this.saveOnWindowClose = true; 89 90 this.textContent = ""; 91 this.appendChild(this.constructor.fragment); 92 this.initializeAttributeInheritance(); 93 94 Services.obs.addObserver( 95 this.resetDefaultGroupName, 96 "intl:app-locales-changed" 97 ); 98 window.addEventListener("unload", () => { 99 Services.obs.removeObserver( 100 this.resetDefaultGroupName, 101 "intl:app-locales-changed" 102 ); 103 }); 104 105 this.addEventListener("click", this); 106 107 this.#labelElement = this.querySelector(".tab-group-label"); 108 this.#labelContainerElement = this.querySelector( 109 ".tab-group-label-container" 110 ); 111 // Mirroring MozTabbrowserTab 112 this.#labelElement.container = gBrowser.tabContainer; 113 this.#labelElement.group = this; 114 115 this.#labelContainerElement.addEventListener("mouseover", this); 116 this.#labelContainerElement.addEventListener("mouseout", this); 117 this.#labelElement.addEventListener("contextmenu", e => { 118 e.preventDefault(); 119 gBrowser.tabGroupMenu.openEditModal(this); 120 return false; 121 }); 122 123 this.#updateLabelAriaAttributes(); 124 125 this.overflowContainer = this.querySelector( 126 ".tab-group-overflow-count-container" 127 ); 128 this.#overflowCountLabel = this.overflowContainer.querySelector( 129 ".tab-group-overflow-count" 130 ); 131 132 let tabGroupCreateDetail = this.#wasCreatedByAdoption 133 ? { isAdoptingGroup: true } 134 : {}; 135 this.dispatchEvent( 136 new CustomEvent("TabGroupCreate", { 137 bubbles: true, 138 detail: tabGroupCreateDetail, 139 }) 140 ); 141 // Reset `wasCreatedByAdoption` to default of false so that we only 142 // claim that a tab group was created by adoption the first time it 143 // mounts after getting created by `Tabbrowser.adoptTabGroup`. 144 this.#wasCreatedByAdoption = false; 145 } 146 147 resetDefaultGroupName = () => { 148 this.#defaultGroupName = ""; 149 this.#updateLabelAriaAttributes(); 150 this.#updateTooltip(); 151 }; 152 153 disconnectedCallback() { 154 this.ownerGlobal.removeEventListener("TabSelect", this); 155 this.#tabChangeObserver?.disconnect(); 156 } 157 158 appendChild(node) { 159 return this.insertBefore(node, this.overflowContainer); 160 } 161 162 #observeTabChanges() { 163 if (!this.#tabChangeObserver) { 164 this.#tabChangeObserver = new window.MutationObserver(mutations => { 165 if (!this.tabs.length) { 166 this.dispatchEvent( 167 new CustomEvent("TabGroupRemoved", { bubbles: true }) 168 ); 169 this.remove(); 170 Services.obs.notifyObservers( 171 this, 172 "browser-tabgroup-removed-from-dom" 173 ); 174 } else { 175 let tabs = this.tabs; 176 let tabCount = tabs.length; 177 let hasActiveTab = false; 178 tabs.forEach((tab, index) => { 179 if (tab.selected) { 180 hasActiveTab = true; 181 } 182 183 // Renumber tabs so that a11y tools can tell users that a given 184 // tab is "2 of 7" in the group, for example. 185 tab.setAttribute("aria-posinset", index + 1); 186 tab.setAttribute("aria-setsize", tabCount); 187 }); 188 this.hasActiveTab = hasActiveTab; 189 this.#updateOverflowLabel(); 190 this.#updateLastTabOrSplitViewAttr(); 191 } 192 for (const mutation of mutations) { 193 for (const addedNode of mutation.addedNodes) { 194 if (gBrowser.isTab(addedNode)) { 195 this.#updateTabAriaHidden(addedNode); 196 } else if (gBrowser.isSplitViewWrapper(addedNode)) { 197 for (const splitViewTab of addedNode.tabs) { 198 this.#updateTabAriaHidden(splitViewTab); 199 } 200 } 201 } 202 for (const removedNode of mutation.removedNodes) { 203 if (gBrowser.isTab(removedNode)) { 204 this.#updateTabAriaHidden(removedNode); 205 } else if (gBrowser.isSplitViewWrapper(removedNode)) { 206 for (const splitViewTab of removedNode.tabs) { 207 this.#updateTabAriaHidden(splitViewTab); 208 } 209 } 210 } 211 } 212 }); 213 } 214 this.#tabChangeObserver.observe(this, { childList: true }); 215 } 216 217 get color() { 218 return this.#colorCode; 219 } 220 221 set color(code) { 222 let diff = code !== this.#colorCode; 223 this.#colorCode = code; 224 this.style.setProperty( 225 "--tab-group-color", 226 `var(--tab-group-color-${code})` 227 ); 228 this.style.setProperty( 229 "--tab-group-color-invert", 230 `var(--tab-group-color-${code}-invert)` 231 ); 232 this.style.setProperty( 233 "--tab-group-color-pale", 234 `var(--tab-group-color-${code}-pale)` 235 ); 236 if (diff) { 237 this.dispatchEvent( 238 new CustomEvent("TabGroupUpdate", { bubbles: true }) 239 ); 240 } 241 } 242 243 get defaultGroupName() { 244 if (!this.#defaultGroupName) { 245 this.#defaultGroupName = gBrowser.tabLocalization.formatValueSync( 246 "tab-group-name-default" 247 ); 248 } 249 return this.#defaultGroupName; 250 } 251 252 get id() { 253 return this.getAttribute("id"); 254 } 255 256 set id(val) { 257 this.setAttribute("id", val); 258 } 259 260 /** 261 * @returns {boolean} 262 */ 263 get hasActiveTab() { 264 return this.hasAttribute("hasactivetab"); 265 } 266 267 /** 268 * @param {boolean} val 269 */ 270 set hasActiveTab(val) { 271 this.toggleAttribute("hasactivetab", val); 272 } 273 274 get label() { 275 return this.#label; 276 } 277 278 set label(val) { 279 let diff = val !== this.#label; 280 this.#label = val; 281 282 // If the group name is empty, use a zero width space so we 283 // always create a text node and get consistent layout. 284 this.setAttribute("label", val || "\u200b"); 285 this.#updateLabelAriaAttributes(); 286 this.#updateTooltip(); 287 if (diff) { 288 this.dispatchEvent( 289 new CustomEvent("TabGroupUpdate", { bubbles: true }) 290 ); 291 } 292 } 293 294 // alias for label 295 get name() { 296 return this.label; 297 } 298 299 set name(newName) { 300 this.label = newName; 301 } 302 303 get collapsed() { 304 return this.hasAttribute("collapsed"); 305 } 306 307 set collapsed(val) { 308 if (!!val == this.collapsed) { 309 return; 310 } 311 if (val) { 312 for (let tab of this.tabs) { 313 // Unlock tab sizes. 314 tab.style.maxWidth = ""; 315 } 316 } 317 this.toggleAttribute("collapsed", val); 318 this.#updateLabelAriaAttributes(); 319 this.#updateTooltip(); 320 this.#updateOverflowLabel(); 321 for (const tab of this.tabs) { 322 this.#updateTabAriaHidden(tab); 323 } 324 gBrowser.tabContainer.previewPanel?.deactivate(this, { force: true }); 325 const eventName = val ? "TabGroupCollapse" : "TabGroupExpand"; 326 this.dispatchEvent(new CustomEvent(eventName, { bubbles: true })); 327 328 let pendingAnimationPromises = this.tabs.flatMap(tab => 329 tab 330 .getAnimations() 331 .filter(anim => 332 ["min-width", "max-width"].includes(anim.transitionProperty) 333 ) 334 .map(anim => anim.finished) 335 ); 336 Promise.allSettled(pendingAnimationPromises).then(() => { 337 this.dispatchEvent( 338 new CustomEvent("TabGroupAnimationComplete", { bubbles: true }) 339 ); 340 }); 341 } 342 343 #lastAddedTo = 0; 344 get lastSeenActive() { 345 return Math.max( 346 this.#lastAddedTo, 347 ...this.tabs.map(t => t.lastSeenActive) 348 ); 349 } 350 351 async #updateLabelAriaAttributes() { 352 let tabGroupName = this.#label || this.defaultGroupName; 353 354 this.#labelElement?.setAttribute("aria-label", tabGroupName); 355 this.#labelElement?.setAttribute("aria-level", 1); 356 357 let tabGroupDescriptionL10nID; 358 if (this.collapsed) { 359 this.#labelElement?.setAttribute("aria-haspopup", "menu"); 360 this.#labelElement?.setAttribute("aria-expanded", "false"); 361 tabGroupDescriptionL10nID = this.hasAttribute("previewpanelactive") 362 ? "tab-group-preview-open-description" 363 : "tab-group-preview-closed-description"; 364 } else { 365 this.#labelElement?.removeAttribute("aria-haspopup"); 366 this.#labelElement?.setAttribute("aria-expanded", "true"); 367 tabGroupDescriptionL10nID = "tab-group-description"; 368 } 369 let tabGroupDescription = await gBrowser.tabLocalization.formatValue( 370 tabGroupDescriptionL10nID, 371 { 372 tabGroupName, 373 } 374 ); 375 this.#labelElement?.setAttribute("aria-description", tabGroupDescription); 376 } 377 378 async #updateTooltip() { 379 // Disable the tooltip for collapsed groups when tab group hover preview is enabled 380 if (this._showTabGroupHoverPreview && this.collapsed) { 381 delete this.dataset.tooltip; 382 return; 383 } 384 385 let tabGroupName = this.#label || this.defaultGroupName; 386 let tooltipKey = this.collapsed 387 ? "tab-group-label-tooltip-collapsed" 388 : "tab-group-label-tooltip-expanded"; 389 await gBrowser.tabLocalization 390 .formatValue(tooltipKey, { 391 tabGroupName, 392 }) 393 .then(result => { 394 this.dataset.tooltip = result; 395 }); 396 } 397 398 /** 399 * @param {MozTabbrowserTab} tab 400 */ 401 #updateTabAriaHidden(tab) { 402 if (tab.splitview) { 403 if ( 404 tab.group?.collapsed && 405 !tab.splitview.tabs.some(splitViewTab => splitViewTab.selected) 406 ) { 407 tab.splitview.setAttribute("aria-hidden", "true"); 408 } else { 409 tab.splitview.removeAttribute("aria-hidden"); 410 } 411 } else if (tab.group?.collapsed && !tab.selected) { 412 tab.setAttribute("aria-hidden", "true"); 413 } else { 414 tab.removeAttribute("aria-hidden"); 415 } 416 } 417 418 #updateOverflowLabel() { 419 // When a group containing the active tab is collapsed, 420 // the overflow count displays the number of additional tabs 421 // in the group adjacent to the active tab. 422 if (this.overflowContainer) { 423 let overflowCountLabel = this.overflowContainer.querySelector( 424 ".tab-group-overflow-count" 425 ); 426 let tabs = this.tabs; 427 let tabCount = tabs.length; 428 const overflowOffset = 429 this.hasActiveTab && gBrowser.selectedTab.splitview ? 2 : 1; 430 431 this.toggleAttribute("hasmultipletabs", tabCount > overflowOffset); 432 433 gBrowser.tabLocalization 434 .formatValue("tab-group-overflow-count", { 435 tabCount: tabCount - overflowOffset, 436 }) 437 .then(result => (overflowCountLabel.textContent = result)); 438 gBrowser.tabLocalization 439 .formatValue("tab-group-overflow-count-tooltip", { 440 tabCount: tabCount - overflowOffset, 441 }) 442 .then(result => { 443 overflowCountLabel.setAttribute("tooltiptext", result); 444 overflowCountLabel.setAttribute("aria-description", result); 445 }); 446 } 447 } 448 449 #updateLastTabOrSplitViewAttr() { 450 const LAST_ITEM_ATTRIBUTE = "last-tab-or-split-view"; 451 let lastTab = this.tabs[this.tabs.length - 1]; 452 let currentLastTabOrSplitView = lastTab.splitview 453 ? lastTab.splitview 454 : lastTab; 455 456 let prevLastTabOrSplitView = this.querySelector( 457 `[${LAST_ITEM_ATTRIBUTE}]` 458 ); 459 if (prevLastTabOrSplitView !== currentLastTabOrSplitView) { 460 prevLastTabOrSplitView?.toggleAttribute(LAST_ITEM_ATTRIBUTE); 461 currentLastTabOrSplitView.toggleAttribute(LAST_ITEM_ATTRIBUTE); 462 } 463 } 464 465 /** 466 * @returns {MozTabbrowserTab[]} 467 */ 468 get tabs() { 469 let childrenArray = Array.from(this.children); 470 for (let i = childrenArray.length - 1; i >= 0; i--) { 471 if (childrenArray[i].tagName == "tab-split-view-wrapper") { 472 childrenArray.splice(i, 1, ...childrenArray[i].tabs); 473 } 474 } 475 return childrenArray.filter(node => node.matches("tab")); 476 } 477 478 /** 479 * @returns {MozTabbrowserTab|MozTabSplitViewWrapper[]} 480 */ 481 get tabsAndSplitViews() { 482 return Array.from(this.children).filter( 483 node => node.matches("tab") || node.tagName == "tab-split-view-wrapper" 484 ); 485 } 486 487 /** 488 * @param {MozTabbrowserTab} tab 489 * @returns {boolean} 490 */ 491 isTabVisibleInGroup(tab) { 492 if (this.isBeingDragged) { 493 return false; 494 } 495 if (this.collapsed && !tab.selected && !tab.multiselected) { 496 return false; 497 } 498 return true; 499 } 500 501 /** 502 * @returns {MozTextLabel} 503 */ 504 get labelElement() { 505 return this.#labelElement; 506 } 507 508 /** 509 * @returns {MozXULElement} 510 */ 511 get labelContainerElement() { 512 return this.#labelContainerElement; 513 } 514 515 get overflowCountLabel() { 516 return this.#overflowCountLabel; 517 } 518 519 /** 520 * @param {boolean} value 521 */ 522 set wasCreatedByAdoption(value) { 523 this.#wasCreatedByAdoption = value; 524 } 525 526 /** 527 * @returns {boolean} 528 */ 529 get isBeingDragged() { 530 return this.hasAttribute("movingtabgroup"); 531 } 532 533 /** 534 * @param {boolean} val 535 */ 536 set isBeingDragged(val) { 537 this.toggleAttribute("movingtabgroup", val); 538 } 539 540 /** 541 * @returns {boolean} 542 */ 543 get hoverPreviewPanelActive() { 544 return this.hasAttribute("previewpanelactive"); 545 } 546 547 /** 548 * @param {boolean} val 549 */ 550 set hoverPreviewPanelActive(val) { 551 this.toggleAttribute("previewpanelactive", val); 552 this.#updateLabelAriaAttributes(); 553 } 554 555 /** 556 * add tabs to the group 557 * 558 * @param {MozTabbrowserTab[] | MozSplitViewWrapper} tabsOrSplitViews 559 * @param {TabMetricsContext} [metricsContext] 560 * Optional context to record for metrics purposes. 561 */ 562 addTabs(tabsOrSplitViews, metricsContext = null) { 563 for (let tabOrSplitView of tabsOrSplitViews) { 564 if (gBrowser.isSplitViewWrapper(tabOrSplitView)) { 565 gBrowser.moveSplitViewToExistingGroup( 566 tabOrSplitView, 567 this, 568 metricsContext 569 ); 570 } else { 571 if (tabOrSplitView.pinned) { 572 tabOrSplitView.ownerGlobal.gBrowser.unpinTab(tabOrSplitView); 573 } 574 let tabToMove = 575 this.ownerGlobal === tabOrSplitView.ownerGlobal 576 ? tabOrSplitView 577 : gBrowser.adoptTab(tabOrSplitView, { 578 tabIndex: gBrowser.tabs.at(-1)._tPos + 1, 579 selectTab: tabOrSplitView.selected, 580 }); 581 gBrowser.moveTabToExistingGroup(tabToMove, this, metricsContext); 582 } 583 } 584 this.#lastAddedTo = Date.now(); 585 } 586 587 /** 588 * Remove all tabs from the group and delete the group. 589 * 590 * @param {TabMetricsContext} [metricsContext] 591 */ 592 ungroupTabs( 593 metricsContext = { 594 isUserTriggered: false, 595 telemetrySource: TabMetrics.METRIC_SOURCE.UNKNOWN, 596 } 597 ) { 598 this.dispatchEvent( 599 new CustomEvent("TabGroupUngroup", { 600 bubbles: true, 601 detail: metricsContext, 602 }) 603 ); 604 for (let i = this.tabs.length - 1; i >= 0; i--) { 605 gBrowser.ungroupTab(this.tabs[i]); 606 } 607 } 608 609 /** 610 * Save group data to session store. 611 * 612 * @param {object} [options] 613 * @param {boolean} [options.isUserTriggered] 614 * Whether or not the save operation was explicitly called by the user. 615 * Used for telemetry. Default is false. 616 */ 617 save({ isUserTriggered = false } = {}) { 618 SessionStore.addSavedTabGroup(this); 619 this.dispatchEvent( 620 new CustomEvent("TabGroupSaved", { 621 bubbles: true, 622 detail: { isUserTriggered }, 623 }) 624 ); 625 } 626 627 saveAndClose({ isUserTriggered } = {}) { 628 this.save({ isUserTriggered }); 629 gBrowser.removeTabGroup(this); 630 } 631 632 /** 633 * @param {PointerEvent} event 634 */ 635 on_click(event) { 636 let isToggleElement = 637 event.target === this.#labelElement || 638 event.target === this.#overflowCountLabel; 639 if (isToggleElement && event.button === 0) { 640 event.preventDefault(); 641 this.collapsed = !this.collapsed; 642 gBrowser.tabGroupMenu.close(); 643 644 /** @type {GleanCounter} */ 645 let interactionMetric = this.collapsed 646 ? Glean.tabgroup.groupInteractions.collapse 647 : Glean.tabgroup.groupInteractions.expand; 648 interactionMetric.add(1); 649 } 650 } 651 652 /** 653 * @param {CustomEvent} event 654 */ 655 on_mouseover(event) { 656 // Only fire the event if we are entering the tab group label. 657 // mouseover also fires events when moving between elements inside the tab group. 658 if (!this.#labelContainerElement.contains(event.relatedTarget)) { 659 this.#labelElement.dispatchEvent( 660 new CustomEvent("TabGroupLabelHoverStart", { bubbles: true }) 661 ); 662 } 663 } 664 665 /** 666 * @param {CustomEvent} event 667 */ 668 on_mouseout(event) { 669 // Only fire the event if we are leaving the tab group label. 670 // mouseout also fires events when moving between elements inside the tab group. 671 if (!this.#labelContainerElement.contains(event.relatedTarget)) { 672 this.#labelElement.dispatchEvent( 673 new CustomEvent("TabGroupLabelHoverEnd", { bubbles: true }) 674 ); 675 } 676 } 677 678 /** 679 * @param {CustomEvent} event 680 */ 681 on_TabSelect(event) { 682 const { previousTab } = event.detail; 683 this.hasActiveTab = event.target.group === this; 684 if (this.hasActiveTab) { 685 this.#updateTabAriaHidden(event.target); 686 } 687 if (previousTab.group === this) { 688 this.#updateTabAriaHidden(previousTab); 689 } 690 691 this.#updateOverflowLabel(); 692 } 693 694 /** 695 * If one of this group's tabs is the selected tab, this will do nothing. 696 * Otherwise, it will expand the group if collapsed, and select the first 697 * tab in its list. 698 */ 699 select() { 700 this.collapsed = false; 701 if (gBrowser.selectedTab.group == this) { 702 return; 703 } 704 gBrowser.selectedTab = this.tabs[0]; 705 } 706 } 707 708 customElements.define("tab-group", MozTabbrowserTabGroup); 709 }