tor-browser

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

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:
Mbrowser/components/tabbrowser/content/tab-hover-preview.mjs | 18+++++++++++++++++-
Mbrowser/components/tabbrowser/content/tabs.js | 46++++++++++++++++++++++++++++++++++++++++++++--
Mbrowser/components/tabbrowser/test/browser/tabs/browser_tab_groups_keyboard_focus.js | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(); +});