Bug 1908431 - add basic support for tab groups in overflow menu. r=dao,fluent-reviewers,desktop-theme-reviewers,tabbrowser-reviewers,bolsson

Differential Revision: https://phabricator.services.mozilla.com/D232389
This commit is contained in:
DJ
2024-12-19 12:04:19 +00:00
parent b91e94e3b6
commit a807e8e024
11 changed files with 470 additions and 1 deletions

View File

@@ -0,0 +1,132 @@
/* 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/. */
export class GroupsPanel {
constructor({ view, containerNode }) {
this.view = view;
this.containerNode = containerNode;
this.win = containerNode.ownerGlobal;
this.doc = containerNode.ownerDocument;
this.panelMultiView = null;
this.view.addEventListener("ViewShowing", this);
}
handleEvent(event) {
switch (event.type) {
case "ViewShowing":
if (event.target == this.view) {
this.panelMultiView = this.view.panelMultiView;
this.#populate(event);
}
break;
case "PanelMultiViewHidden":
if ((this.panelMultiView = event.target)) {
this.#cleanup();
this.panelMultiView = null;
}
break;
case "command":
this.#handleCommand(event);
break;
}
}
#handleCommand(event) {
let groupId = event.target.closest("toolbaritem").groupId;
switch (event.target.dataset.command) {
case "allTabsGroupView_selectGroup":
let group = this.win.gBrowser.getTabGroupById(groupId);
group.select();
group.ownerGlobal.focus();
break;
case "allTabsGroupView_restoreGroup":
this.win.SessionStore.openSavedTabGroup(groupId, this.win);
break;
}
}
#setupListeners() {
this.view.addEventListener("command", this);
this.view.panelMultiView.addEventListener("PanelMultiViewHidden", this);
}
#cleanup() {
this.containerNode.innerHTML = "";
this.view.removeEventListener("command", this);
}
#populate() {
let fragment = this.doc.createDocumentFragment();
let otherWindowGroups = this.win.gBrowser
.getAllTabGroups()
.filter(group => {
return group.ownerGlobal !== this.win;
});
let savedGroups = this.win.SessionStore.savedGroups;
if (savedGroups.length + otherWindowGroups.length > 0) {
let header = this.doc.createElement("h2");
header.setAttribute("class", "subview-subheader");
this.doc.l10n.setAttributes(header, "tab-group-menu-header");
fragment.appendChild(header);
}
for (let groupData of otherWindowGroups) {
fragment.appendChild(this.#createRow(groupData));
}
for (let groupData of savedGroups) {
fragment.appendChild(this.#createRow(groupData, "closed"));
}
this.containerNode.appendChild(fragment);
this.#setupListeners();
}
#createRow(group, kind = "open") {
let { doc } = this;
let row = doc.createXULElement("toolbaritem");
row.setAttribute("class", "all-tabs-item all-tabs-group-item");
row.setAttribute("context", "tabContextMenu");
row.style.setProperty(
"--tab-group-color",
`var(--tab-group-color-${group.color})`
);
row.style.setProperty(
"--tab-group-color-invert",
`var(--tab-group-color-${group.color}-invert)`
);
row.style.setProperty(
"--tab-group-color-pale",
`var(--tab-group-color-${group.color}-pale)`
);
row.groupId = group.id;
let button = doc.createXULElement("toolbarbutton");
button.setAttribute(
"class",
"all-tabs-button subviewbutton subviewbutton-iconic all-tabs-group-action-button"
);
if (kind != "open") {
button.classList.add("all-tabs-group-saved-group");
button.dataset.command = "allTabsGroupView_restoreGroup";
} else {
button.dataset.command = "allTabsGroupView_selectGroup";
}
button.setAttribute("flex", "1");
button.setAttribute("crop", "end");
if (group.name) {
button.setAttribute("label", group.name);
} else {
doc.l10n
.formatValues([{ id: "tab-group-name-default" }])
.then(([msg]) => {
button.setAttribute("label", msg);
});
}
row.appendChild(button);
return row;
}
}

View File

@@ -102,9 +102,14 @@ class TabsListBase {
*/
_populate() {
let fragment = this.doc.createDocumentFragment();
let currentGroupId;
for (let tab of this.gBrowser.tabs) {
if (this.filterFn(tab)) {
if (tab.group && tab.group.id != currentGroupId) {
fragment.appendChild(this._createGroupRow(tab.group));
currentGroupId = tab.group.id;
}
fragment.appendChild(this._createRow(tab));
}
}
@@ -121,6 +126,10 @@ class TabsListBase {
* Remove the menuitems from the DOM, cleanup internal state and listeners.
*/
_cleanup() {
this.doc
.querySelectorAll(".all-tabs-group-button")
.forEach(node => node.remove());
for (let item of this.rows) {
item.remove();
}
@@ -262,6 +271,9 @@ export class TabsPanel extends TabsListBase {
this.gBrowser.removeTab(event.target.tab);
break;
}
if (event.target.classList.contains("all-tabs-group-button")) {
this.gBrowser.getTabGroupById(event.target.groupId).select();
}
// fall through
default:
super.handleEvent(event);
@@ -275,9 +287,12 @@ export class TabsPanel extends TabsListBase {
// The loading throbber can't be set until the toolbarbutton is rendered,
// so set the image attributes again now that the elements are in the DOM.
for (let row of this.rows) {
// Ensure this isn't a group label
if (row.tab) {
this._setImageAttributes(row, row.tab);
}
}
}
_selectTab(tab) {
super._selectTab(tab);
@@ -324,6 +339,10 @@ export class TabsPanel extends TabsListBase {
});
}
if (tab.group) {
row.classList.add("grouped");
}
row.appendChild(button);
let muteButton = doc.createXULElement("toolbarbutton");
@@ -352,6 +371,49 @@ export class TabsPanel extends TabsListBase {
return row;
}
_createGroupRow(group) {
let { doc } = this;
let row = doc.createXULElement("toolbaritem");
row.setAttribute("class", "all-tabs-item all-tabs-group-item");
row.setAttribute("context", "none");
row.style.setProperty(
"--tab-group-color",
`var(--tab-group-color-${group.color})`
);
row.style.setProperty(
"--tab-group-color-invert",
`var(--tab-group-color-${group.color}-invert)`
);
row.style.setProperty(
"--tab-group-color-pale",
`var(--tab-group-color-${group.color}-pale)`
);
row.addEventListener("command", this);
let button = doc.createXULElement("toolbarbutton");
button.setAttribute(
"class",
"all-tabs-button all-tabs-group-button subviewbutton subviewbutton-iconic"
);
button.setAttribute("flex", "1");
button.setAttribute("crop", "end");
button.group = group;
button.groupId = group.id;
if (group.label) {
button.label = group.label;
} else {
doc.l10n
.formatValues([{ id: "tab-group-name-default" }])
.then(([msg]) => {
button.label = msg;
});
}
button.image = "chrome://browser/skin/tabbrowser/tab-group-chicklet.svg";
row.appendChild(button);
return row;
}
_setRowAttributes(row, tab) {
setAttributes(row, { selected: tab.selected });

View File

@@ -23,6 +23,9 @@
class="subviewbutton subviewbutton-nav"
closemenu="none"
data-l10n-id="all-tabs-menu-hidden-tabs"/>
<toolbarseparator id="allTabsMenu-groupsSeparator"/>
<vbox id="allTabsMenu-groupsView" class="panel-subview-body">
</vbox>
<toolbarseparator id="allTabsMenu-tabsSeparator"/>
<vbox id="allTabsMenu-dropIndicatorHolder">
<vbox id="allTabsMenu-dropIndicator" collapsed="true"/>

View File

@@ -7,6 +7,8 @@
ChromeUtils.defineESModuleGetters(this, {
BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs",
GroupsPanel: "resource:///modules/GroupsList.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
TabsPanel: "resource:///modules/TabsList.sys.mjs",
});
@@ -19,6 +21,7 @@ var gTabsPanel = {
containerTabsView: "allTabsMenu-containerTabsView",
hiddenTabsButton: "allTabsMenu-hiddenTabsButton",
hiddenTabsView: "allTabsMenu-hiddenTabsView",
groupsView: "allTabsMenu-groupsView",
},
_initialized: false,
_initializedElements: false,
@@ -60,6 +63,11 @@ var gTabsPanel = {
containerNode: this.allTabsViewTabs,
filterFn: tab => !tab.hidden,
dropIndicator: this.dropIndicator,
showGroups: true,
});
this.groupsPanel = new GroupsPanel({
view: this.allTabsView,
containerNode: this.groupsView,
});
this.allTabsView.addEventListener("ViewShowing", () => {

View File

@@ -138,6 +138,15 @@
this.#updateLabelAriaAttributes();
}
// alias for label
get name() {
return this.label;
}
set name(newName) {
this.label = newName;
}
get collapsed() {
return this.hasAttribute("collapsed");
}
@@ -224,6 +233,19 @@
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);

View File

@@ -9,6 +9,7 @@ JAR_MANIFESTS += ["jar.mn"]
EXTRA_JS_MODULES += [
"AsyncTabSwitcher.sys.mjs",
"GroupsList.sys.mjs",
"NewTabPagePreloading.sys.mjs",
"OpenInTabsUtils.sys.mjs",
"TabsList.sys.mjs",

View File

@@ -495,6 +495,9 @@ tags = "vertical-tabs"
["browser_tab_manager_drag.js"]
tags = "vertical-tabs"
["browser_tab_manager_groups.js"]
tags = "vertical-tabs"
["browser_tab_manager_keyboard_access.js"]
tags = "vertical-tabs"

View File

@@ -590,6 +590,33 @@ add_task(async function test_moveTabBetweenGroups() {
await removeTabGroup(group2);
});
add_task(async function test_tabGroupSelect() {
let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab3 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab1Added = BrowserTestUtils.waitForEvent(tab1, "TabGrouped");
let tab2Added = BrowserTestUtils.waitForEvent(tab2, "TabGrouped");
let group = gBrowser.addTabGroup([tab1, tab2]);
await Promise.allSettled([tab1Added, tab2Added]);
gBrowser.selectTabAtIndex(tab3._tPos);
Assert.ok(tab3.selected, "Tab 3 is selected");
group.select();
Assert.ok(group.tabs[0].selected, "First tab is selected");
gBrowser.selectTabAtIndex(group.tabs[1]._tPos);
Assert.ok(group.tabs[1].selected, "Second tab is selected");
group.select();
Assert.ok(group.tabs[1].selected, "Second tab is still selected");
group.collapsed = true;
Assert.ok(group.collapsed, "Group is collapsed");
Assert.ok(tab3.selected, "Tab 3 is selected");
group.select();
Assert.ok(!group.collapsed, "Group is no longer collapsed");
Assert.ok(group.tabs[0].selected, "First tab in group is selected");
await removeTabGroup(group);
BrowserTestUtils.removeTab(tab3);
});
// Context menu tests
// ---

View File

@@ -0,0 +1,181 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [["browser.tabs.groups.enabled", true]],
});
const tabGroups = SessionStore.getSavedTabGroups();
tabGroups.forEach(tabGroup => SessionStore.forgetSavedTabGroup(tabGroup.id));
window.gTabsPanel.init();
});
async function openTabsMenu(win = window) {
return new Promise(resolve => {
BrowserTestUtils.waitForEvent(
win.document.getElementById("allTabsMenu-allTabsView"),
"ViewShown"
).then(event => resolve(event.target));
win.document.getElementById("alltabs-button").click();
});
}
async function closeTabsMenu(win = window) {
return new Promise(resolve => {
let panel = win.document
.getElementById("allTabsMenu-allTabsView")
.closest("panel");
BrowserTestUtils.waitForPopupEvent(panel, "hidden").then(event =>
resolve(event.target)
);
panel.hidePopup();
});
}
/**
* Tests that grouped tabs in alltabsmenu are prepended by
* a group indicator
*/
add_task(async function test_allTabsView() {
let tabs = [];
for (let i = 1; i <= 5; i++) {
tabs.push(
await addTab(`data:text/plain,tab${i}`, {
skipAnimation: true,
})
);
}
gBrowser.addTabGroup([tabs[0], tabs[1]], {
label: "Test Group",
});
gBrowser.addTabGroup([tabs[2], tabs[3]]);
let allTabsMenu = await openTabsMenu();
let tabButtons = allTabsMenu.querySelectorAll(
"#allTabsMenu-allTabsView-tabs .all-tabs-button"
);
let expectedLabels = [
"New Tab",
"data:text/plain,tab5",
"Test Group",
"data:text/plain,tab1",
"data:text/plain,tab2",
"Unnamed Group",
"data:text/plain,tab3",
"data:text/plain,tab4",
];
tabButtons.forEach((button, i) => {
Assert.equal(
button.label,
expectedLabels[i],
`Expected: ${expectedLabels[i]}`
);
});
await closeTabsMenu();
for (let tab of tabs) {
BrowserTestUtils.removeTab(tab);
}
});
/**
* Tests that groups appear in the supplementary group menu
* when they are saved (and closed,) or open in another window.
* Clicking an open group in this menu focuses it,
* and clicking on a saved group restores it.
*/
add_task(async function test_tabGroupsView() {
let tabs = [];
for (let i = 1; i <= 5; i++) {
tabs.push(
await addTab(`data:text/plain,tab${i}`, {
skipAnimation: true,
})
);
}
let group1 = gBrowser.addTabGroup([tabs[0], tabs[1]], {
id: "test-saved-group",
label: "Test Saved Group",
});
let group2 = gBrowser.addTabGroup([tabs[2], tabs[3]], {
label: "Test Open Group",
});
let newWindow = await BrowserTestUtils.openNewBrowserWindow();
newWindow.gTabsPanel.init();
let allTabsMenu = await openTabsMenu(newWindow);
Assert.equal(
allTabsMenu.querySelectorAll("#allTabsMenu-groupsView .all-tabs-button")
.length,
2,
"Both groups shown in groups list"
);
Assert.ok(
!allTabsMenu.querySelector(
"#allTabsMenu-groupsView .all-tabs-button.all-tabs-group-saved-group"
),
"Neither group is shown as saved"
);
await closeTabsMenu(newWindow);
group1.save();
await removeTabGroup(group1);
Assert.ok(!gBrowser.getTabGroupById("test-saved-group"), "Group 1 removed");
allTabsMenu = await openTabsMenu(newWindow);
Assert.equal(
allTabsMenu.querySelectorAll("#allTabsMenu-groupsView .all-tabs-button")
.length,
2,
"Both groups shown in groups list"
);
let savedGroupButton = allTabsMenu.querySelector(
"#allTabsMenu-groupsView .all-tabs-button.all-tabs-group-saved-group"
);
Assert.equal(
savedGroupButton.label,
"Test Saved Group",
"Saved group appears as saved"
);
// Clicking on an open group should select that group in the origin window
let openGroupButton = allTabsMenu.querySelector(
"#allTabsMenu-groupsView .all-tabs-button:not(.all-tabs-group-saved-group)"
);
openGroupButton.click();
Assert.equal(
gBrowser.selectedTab.group.id,
group2.id,
"Tab in group 2 is selected"
);
await BrowserTestUtils.closeWindow(newWindow, { animate: false });
// Clicking on a saved group should restore the group to the current window
allTabsMenu = await openTabsMenu();
savedGroupButton = allTabsMenu.querySelector(
"#allTabsMenu-groupsView .all-tabs-button.all-tabs-group-saved-group"
);
savedGroupButton.click();
group1 = gBrowser.getTabGroupById("test-saved-group");
Assert.ok(group1, "Group 1 has been restored");
allTabsMenu = await openTabsMenu();
Assert.ok(
!allTabsMenu.querySelector("#allTabsMenu-groupsView .all-tabs-button"),
"Groups list is now empty for this window"
);
await closeTabsMenu();
for (let tab of tabs) {
BrowserTestUtils.removeTab(tab);
}
await removeTabGroup(group1);
});

View File

@@ -191,6 +191,7 @@ tabbrowser-manager-close-tab =
## Tab Groups
tab-group-name-default = Unnamed Group
tab-group-editor-title-create = Create tab group
tab-group-editor-title-edit = Manage tab group
tab-group-editor-name-label = Name
@@ -200,6 +201,8 @@ tab-group-editor-cancel =
.label = Cancel
.accesskey = C
tab-group-menu-header = Tab groups
tab-context-unnamed-group =
.label = Unnamed group

View File

@@ -1465,8 +1465,22 @@ toolbar:not(#TabsToolbar) #firefox-view-button {
}
}
#allTabsMenu-groupsView {
flex: auto;
flex-grow: 0;
&:empty,
&:empty + toolbarseparator {
display: none;
}
}
.all-tabs-item {
margin-inline: var(--arrowpanel-menuitem-margin-inline);
&.grouped {
margin-inline-start: calc(var(--arrowpanel-menuitem-margin-inline) * 2);
}
border-radius: var(--arrowpanel-menuitem-border-radius);
&[selected] {
@@ -1497,6 +1511,19 @@ toolbar:not(#TabsToolbar) #firefox-view-button {
}
}
.all-tabs-group-item {
> .subviewbutton-iconic > .toolbarbutton-icon {
list-style-image: url("chrome://browser/skin/tabbrowser/tab-group-chicklet.svg");
fill: light-dark(var(--tab-group-color), var(--tab-group-color-invert));
}
> .all-tabs-group-saved-group > .toolbarbutton-icon {
-moz-context-properties: fill, stroke;
fill: transparent;
stroke: light-dark(var(--tab-group-color), var(--tab-group-color-invert));
}
}
.all-tabs-button {
list-style-image: url("chrome://global/skin/icons/defaultFavicon.svg");
}