commit 35c0a95db213a1b081f115bae1eaf4bd3e8aee8d
parent 91652277d3440d08d68a2f74d6ac3a893d8aae7a
Author: DJ <dj@walker.dev>
Date: Tue, 14 Oct 2025 21:10:11 +0000
Bug 1971238 - support keyboard navigation for tab group previews. r=sthompson,accessibility-frontend-reviewers,tabbrowser-reviewers,ayeddi
Differential Revision: https://phabricator.services.mozilla.com/D266367
Diffstat:
3 files changed, 146 insertions(+), 3 deletions(-)
diff --git a/browser/components/tabbrowser/content/tab-hover-preview.mjs b/browser/components/tabbrowser/content/tab-hover-preview.mjs
@@ -506,6 +506,16 @@ class TabGroupPanel extends Panel {
});
}
+ /**
+ * Move keyboard focus into the group preview panel.
+ *
+ * @param {-1|1} [dir] Whether to focus the beginning or end of the list.
+ */
+ focusPanel(dir = 1) {
+ let childIndex = dir > 0 ? 0 : this.panelContent.children.length - 1;
+ this.panelContent.children[childIndex].focus();
+ }
+
deactivate({ force = false } = {}) {
if (force) {
this.win.clearTimeout(this.#deactivateTimer);
@@ -570,13 +580,19 @@ class TabGroupPanel extends Panel {
for (let tab of this.#group.tabs) {
let tabbutton = this.win.document.createXULElement("toolbarbutton");
tabbutton.setAttribute("role", "button");
+ tabbutton.setAttribute("keyNav", false);
+ tabbutton.setAttribute("tabindex", 0);
tabbutton.setAttribute("label", tab.label);
tabbutton.setAttribute(
"image",
"page-icon:" + tab.linkedBrowser.currentURI.spec
);
tabbutton.setAttribute("tooltiptext", tab.label);
- tabbutton.classList.add("subviewbutton", "subviewbutton-iconic");
+ tabbutton.classList.add(
+ "subviewbutton",
+ "subviewbutton-iconic",
+ "group-preview-button"
+ );
if (tab == this.win.gBrowser.selectedTab) {
tabbutton.classList.add("active-tab");
}
diff --git a/browser/components/tabbrowser/content/tabs.js b/browser/components/tabbrowser/content/tabs.js
@@ -334,12 +334,20 @@
this.previewPanel?.deactivate(event.target);
}
- on_TabGroupLabelHoverStart(event) {
+ cancelTabGroupPreview() {
+ this.previewPanel?.panelOpener.clear();
+ }
+
+ showTabGroupPreview(group) {
if (!this._showTabGroupHoverPreview) {
return;
}
this.ensureTabPreviewPanelLoaded();
- this.previewPanel.activate(event.target.group);
+ this.previewPanel.activate(group);
+ }
+
+ on_TabGroupLabelHoverStart(event) {
+ this.showTabGroupPreview(event.target.group);
}
on_TabGroupLabelHoverEnd(event) {
@@ -644,12 +652,23 @@
this.ariaFocusedItem = this.selectedItem;
}
}
+ let focusReturnedFromGroupPanel = event.relatedTarget?.classList.contains(
+ "group-preview-button"
+ );
+ if (
+ !focusReturnedFromGroupPanel &&
+ this.tablistHasFocus &&
+ isTabGroupLabel(this.ariaFocusedItem)
+ ) {
+ this.showTabGroupPreview(this.ariaFocusedItem.group);
+ }
}
/**
* @param {FocusEvent} event
*/
on_focusout(event) {
+ this.cancelTabGroupPreview();
if (event.target == this.selectedItem) {
this.tablistHasFocus = false;
}
@@ -934,6 +953,12 @@
let itemToFocus = this.ariaFocusableItems[newIndex];
this.ariaFocusedItem = itemToFocus;
+
+ // If the newly-focused item is a tab group label and the group is collapsed,
+ // proactively show the tab group preview
+ if (isTabGroupLabel(this.ariaFocusedItem)) {
+ this.showTabGroupPreview(this.ariaFocusedItem.group);
+ }
}
_invalidateCachedTabs() {
@@ -967,6 +992,17 @@
* @param {boolean} shouldWrap
*/
advanceSelectedItem(aDir, aWrap) {
+ let groupPanel = this.previewPanel?.tabGroupPanel;
+ if (groupPanel && groupPanel.isActive) {
+ // if the group panel is open, it should receive keyboard focus here
+ // instead of moving to the next item in the tabstrip.
+ groupPanel.focusPanel(aDir);
+ return;
+ }
+
+ // cancel any pending group popup since we expect to deselect the label
+ this.cancelTabGroupPreview();
+
let { ariaFocusableItems, ariaFocusedIndex } = this;
// Advance relative to the ARIA-focused item if set, otherwise advance
@@ -1003,6 +1039,12 @@
this._selectNewTab(newItem, aDir, aWrap);
}
this.ariaFocusedItem = newItem;
+
+ // If the newly-focused item is a tab group label and the group is collapsed,
+ // proactively show the tab group preview
+ if (isTabGroupLabel(this.ariaFocusedItem)) {
+ this.showTabGroupPreview(this.ariaFocusedItem.group);
+ }
}
ensureTabPreviewPanelLoaded() {
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups_keyboard_focus.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups_keyboard_focus.js
@@ -285,3 +285,88 @@ add_task(async function test_TabGroupKeyboardMovement() {
BrowserTestUtils.removeTab(tab1);
BrowserTestUtils.removeTab(tab4);
});
+
+add_task(async function test_TabGroupPreviewKeyboardMovement() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tooltip.delay_ms", 0]],
+ });
+ const previewElement = document.getElementById("tabgroup-preview-panel");
+
+ let groupedTab = await addTab("about:blank");
+ let group = gBrowser.addTabGroup([groupedTab]);
+ let ungroupedTab = await addTab("about:blank");
+ await TabGroupTestUtils.toggleCollapsed(group, true);
+
+ // focus ungrouped tab
+ await BrowserTestUtils.switchTab(gBrowser, ungroupedTab);
+ Services.focus.setFocus(ungroupedTab, Services.focus.FLAG_BYKEY);
+ is(document.activeElement, ungroupedTab, "Ungrouped tab is focused");
+ is(
+ gBrowser.tabContainer.ariaFocusedItem,
+ ungroupedTab,
+ "Ungrouped tab is keyboard-focused"
+ );
+
+ // move focus to group label, triggering preview to open
+ let groupPreviewOpened = BrowserTestUtils.waitForPopupEvent(
+ previewElement,
+ "shown"
+ );
+ await synthesizeKeyForKeyboardMovement(group.labelElement, "KEY_ArrowLeft");
+ is(
+ gBrowser.tabContainer.ariaFocusedItem,
+ group.labelElement,
+ "group label is focused"
+ );
+ await groupPreviewOpened;
+ Assert.equal(
+ previewElement.state,
+ "open",
+ "Preview opens when focused by the keyboard"
+ );
+
+ // Cancel preview with esc key
+ let groupPreviewHidden = BrowserTestUtils.waitForPopupEvent(
+ previewElement,
+ "hidden"
+ );
+ await EventUtils.synthesizeKey("KEY_Escape");
+ await groupPreviewHidden;
+ is(previewElement.state, "closed", "Preview closes when esc is pressed");
+
+ // reopen preview
+ await synthesizeKeyForKeyboardMovement(ungroupedTab, "KEY_ArrowRight");
+ is(
+ gBrowser.tabContainer.ariaFocusedItem,
+ ungroupedTab,
+ "Focus moved back to ungrouped tab"
+ );
+ groupPreviewOpened = BrowserTestUtils.waitForPopupEvent(
+ previewElement,
+ "shown"
+ );
+ await synthesizeKeyForKeyboardMovement(group.labelElement, "KEY_ArrowLeft");
+ await groupPreviewOpened;
+ is(
+ previewElement.state,
+ "open",
+ "Preview opened again after toggling focus back to group label"
+ );
+
+ // select grouped tab in preview using the arrow keys
+ let groupedTabSelected = BrowserTestUtils.waitForEvent(window, "TabSelect");
+ await EventUtils.synthesizeKey("KEY_ArrowDown");
+ await EventUtils.synthesizeKey(" ");
+ await groupedTabSelected;
+
+ is(gBrowser.selectedTab, groupedTab, "Grouped tab is now the active tab");
+ is(
+ gBrowser.tabContainer.ariaFocusedItem,
+ group.labelElement,
+ "Group label remains keyboard focused"
+ );
+
+ await TabGroupTestUtils.removeTabGroup(group);
+ BrowserTestUtils.removeTab(ungroupedTab);
+ await SpecialPowers.popPrefEnv();
+});