tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }