tor-browser

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

commit 7452559d1a71bf42efdf56d136c0207ae6797fec
parent f9ca88edb1601f1254b41cf305829aa32d939e8a
Author: Kelly Cochrane <kcochrane@mozilla.com>
Date:   Wed,  1 Oct 2025 13:18:17 +0000

Bug 1985522 - Handle keyboard navigation and HCM for split view tabs styling r=tabbrowser-reviewers,jsudiaman,fluent-reviewers,ayeddi,bolsson,dao,accessibility-frontend-reviewers,desktop-theme-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D264858

Diffstat:
Mbrowser/components/tabbrowser/content/tab.js | 29+++++++++++++++++++++++++++++
Mbrowser/components/tabbrowser/content/tabbrowser.js | 15+--------------
Mbrowser/components/tabbrowser/content/tabs.js | 6++++++
Mbrowser/components/tabbrowser/content/tabsplitview.js | 27++++++++++++++++++++-------
Mbrowser/components/tabbrowser/test/browser/tabs/browser.toml | 2++
Abrowser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_keyboard_focus.js | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/locales/en-US/browser/tabbrowser.ftl | 8++++++++
Mbrowser/themes/shared/tabbrowser/tabs.css | 13+++++++++++++
8 files changed, 180 insertions(+), 21 deletions(-)

diff --git a/browser/components/tabbrowser/content/tab.js b/browser/components/tabbrowser/content/tab.js @@ -109,6 +109,7 @@ #lastGroup; connectedCallback() { this.#updateOnTabGrouped(); + this.#updateOnTabSplit(); this.#lastGroup = this.group; this.initialize(); @@ -116,6 +117,7 @@ disconnectedCallback() { this.#updateOnTabUngrouped(); + this.#updateOnTabUnsplit(); } initialize() { @@ -766,6 +768,33 @@ this.removeAttribute("aria-setsize"); } } + + #updateOnTabSplit() { + if (this.splitview) { + this.setAttribute("aria-level", 2); + + // Add "Split view" to label if tab is within a split view + let splitViewLabel = gBrowser.tabLocalization.formatValueSync( + "tabbrowser-tab-label-tab-split-view" + ); + this.setAttribute( + "aria-label", + `${this.getAttribute("label")}, ${splitViewLabel}` + ); + } + } + + #updateOnTabUnsplit() { + if (this.splitview) { + this.setAttribute("aria-level", 1); + // `posinset` and `setsize` only need to be set explicitly + // on split view tabs so that a11y tools can tell users that a + // given tab is "1 of 2" in the split view, for example. + this.removeAttribute("aria-posinset"); + this.removeAttribute("aria-setsize"); + this.removeAttribute("aria-label"); + } + } } customElements.define("tabbrowser-tab", MozTabbrowserTab, { diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js @@ -3215,18 +3215,6 @@ } /** - * Removes the split view. This has the effect of closing all the tabs - * in the split view. - * - * @param {MozTabSplitViewWrapper} [splitView] - * The split view to remove. - */ - async removeSplitView(splitView) { - this.removeTabs(splitView.tabs); - splitView.remove(); - } - - /** * Removes a tab from a split view wrapper. This also removes the split view wrapper component * * @param {MozTabSplitViewWrapper} [splitView] @@ -6569,7 +6557,7 @@ } this.#handleTabMove(aTab, () => - aSplitViewWrapper.wrapper.appendChild(aTab) + aSplitViewWrapper.container.appendChild(aTab) ); this.removeFromMultiSelectedTabs(aTab); this.tabContainer._notifyBackgroundTab(aTab); @@ -7469,7 +7457,6 @@ } // Add a line to the tooltip with additional tab context (e.g. container - // membership, tab group membership) when applicable. let containerName = tab.userContextId ? ContextualIdentityService.getUserContextLabel(tab.userContextId) : ""; diff --git a/browser/components/tabbrowser/content/tabs.js b/browser/components/tabbrowser/content/tabs.js @@ -902,6 +902,12 @@ tab.elementIndex = elementIndex++; }); focusableItems.push(...visibleTabsInGroup); + } else if (child.tagName == "tab-split-view-wrapper") { + let visibleTabsInSplitView = child.tabs.filter(tab => tab.visible); + visibleTabsInSplitView.forEach(tab => { + tab.elementIndex = elementIndex++; + }); + focusableItems.push(...visibleTabsInSplitView); } } diff --git a/browser/components/tabbrowser/content/tabsplitview.js b/browser/components/tabbrowser/content/tabsplitview.js @@ -41,8 +41,6 @@ } connectedCallback() { - this.#observeTabChanges(); - // Set up TabSelect listener, as this gets // removed in disconnectedCallback this.ownerGlobal.addEventListener("TabSelect", this); @@ -58,9 +56,11 @@ this.#containerElement = this.querySelector(".tab-split-view-container"); + this.#observeTabChanges(); + // Mirroring MozTabbrowserTab this.#containerElement.container = gBrowser.tabContainer; - this.wrapper = this.#containerElement; + this.container = this.#containerElement; } disconnectedCallback() { @@ -73,12 +73,25 @@ if (!this.#tabChangeObserver) { this.#tabChangeObserver = new window.MutationObserver(() => { if (this.tabs.length) { - let hasActiveTab = this.tabs.some(tab => tab.selected); - this.hasActiveTab = hasActiveTab; + this.hasActiveTab = this.tabs.some(tab => tab.selected); + this.tabs.forEach((tab, index) => { + // Renumber tabs so that a11y tools can tell users that a given + // tab is "1 of 2" in the split view, for example. + tab.setAttribute("aria-posinset", index + 1); + tab.setAttribute("aria-setsize", this.tabs.length); + }); + } else { + this.remove(); + } + + if (this.tabs.length < 2) { + this.unsplitTabs(); } }); } - this.#tabChangeObserver.observe(this, { childList: true }); + this.#tabChangeObserver.observe(this.#containerElement, { + childList: true, + }); } get splitViewId() { @@ -163,7 +176,7 @@ * Close all tabs in the split view wrapper and delete the split view. */ close() { - gBrowser.removeSplitView(this); + gBrowser.removeTabs(this.#tabs); } /** diff --git a/browser/components/tabbrowser/test/browser/tabs/browser.toml b/browser/components/tabbrowser/test/browser/tabs/browser.toml @@ -577,6 +577,8 @@ tags = "vertical-tabs" ["browser_tab_splitview.js"] +["browser_tab_splitview_keyboard_focus.js"] + ["browser_tab_tooltips.js"] tags = "vertical-tabs" skip-if = [ diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_keyboard_focus.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_keyboard_focus.js @@ -0,0 +1,101 @@ +"use strict"; + +add_setup(() => + SpecialPowers.pushPrefEnv({ + set: [["test.wait300msAfterTabSwitch", true]], + }) +); + +/** + * Presses Cmd + arrow key/Ctrl + arrow key in order to move the keyboard + * focused item in the tab strip left or right + * + * @param {MozTabbrowserTab|MozTextLabel} element + * @param {"KEY_ArrowUp"|"KEY_ArrowDown"|"KEY_ArrowLeft"|"KEY_ArrowRight"} keyName + * @returns {Promise<true>} + */ +function synthesizeKeyToChangeKeyboardFocus(element, keyName) { + let focused = TestUtils.waitForCondition(() => { + return element.classList.contains("tablist-keyboard-focus"); + }, "Waiting for element to get keyboard focus"); + EventUtils.synthesizeKey(keyName, { accelKey: true }); + return focused; +} + +/** + * Presses an arrow key in order to change the active tab to the left or right. + * + * @param {MozTabbrowserTab|MozTextLabel} element + * @param {"KEY_ArrowUp"|"KEY_ArrowDown"|"KEY_ArrowLeft"|"KEY_ArrowRight"} keyName + * @returns {Promise<true>} + */ +function synthesizeKeyForKeyboardMovement(element, keyName) { + let focused = TestUtils.waitForCondition(() => { + return ( + element.classList.contains("tablist-keyboard-focus") || + document.activeElement == element + ); + }, "Waiting for element to become active tab and/or get keyboard focus"); + EventUtils.synthesizeKey(keyName); + return focused; +} + +add_task(async function test_SplitViewKeyboardFocus() { + const tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const tab3 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const tab4 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + + const splitView = gBrowser.addTabSplitView([tab2, tab3], { + insertBefore: tab2, + }); + + await BrowserTestUtils.switchTab(gBrowser, tab2); + + // The user normally needs to hit Tab/Shift+Tab in order to cycle + // focused elements until the active tab is focused, but the number + // of focusable elements in the browser changes depending on the user's + // UI customizations. This code is forcing the focus to the active tab + // to avoid any dependencies on specific UI configuration. + info("Move focus to the active tab"); + Services.focus.setFocus(tab2, Services.focus.FLAG_BYKEY); + + is(document.activeElement, tab2, "Tab2 should be focused"); + is( + gBrowser.tabContainer.ariaFocusedItem, + tab2, + "Tab2 should be keyboard-focused as well" + ); + + await synthesizeKeyToChangeKeyboardFocus(tab1, "KEY_ArrowLeft"); + is( + gBrowser.tabContainer.ariaFocusedItem, + tab1, + "Tab1 should be keyboard-focused" + ); + + await synthesizeKeyToChangeKeyboardFocus(tab2, "KEY_ArrowRight"); + is( + gBrowser.tabContainer.ariaFocusedItem, + tab2, + "keyboard focus should move right from tab1 to tab2" + ); + + await synthesizeKeyToChangeKeyboardFocus(tab1, "KEY_ArrowUp"); + is( + gBrowser.tabContainer.ariaFocusedItem, + tab1, + "keyboard focus 'up' should move left from tab2 to tab1 with LTR GUI" + ); + + await synthesizeKeyToChangeKeyboardFocus(tab2, "KEY_ArrowDown"); + is( + gBrowser.tabContainer.ariaFocusedItem, + tab2, + "keyboard focus 'down' should move down from tab1 to tab2 with LTR GUI" + ); + + splitView.close(); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab4); +}); diff --git a/browser/locales/en-US/browser/tabbrowser.ftl b/browser/locales/en-US/browser/tabbrowser.ftl @@ -355,3 +355,11 @@ tab-group-context-open-saved-group-in-this-window = # open the tab group in that window. tab-group-context-open-saved-group-in-new-window = .label = Open Group in New Window + +## Split View + +# Split view tabs display their respective contents side by side +# Displayed within the tooltip on tabs inside of a tab split view +tabbrowser-tab-label-tab-split-view = Split view + +## diff --git a/browser/themes/shared/tabbrowser/tabs.css b/browser/themes/shared/tabbrowser/tabs.css @@ -1051,9 +1051,15 @@ margin-inline: var(--space-small); width: 100%; padding: var(--space-xsmall); + border-radius: var(--tab-border-radius); tab-split-view-wrapper[hasactivetab] > & { background-color: var(--tab-hover-background-color); + + @media (forced-colors) { + border: 1px solid SelectedItem; + background: ButtonFace; + } } > .tabbrowser-tab { @@ -1090,12 +1096,19 @@ } tab-split-view-wrapper:not([hasactivetab]) & { + outline: var(--tab-outline); + outline-offset: var(--tab-outline-offset); + &:hover { background-color: var(--tab-hover-background-color); outline-color: var(--tab-hover-outline-color); } .tabbrowser-tab { + .tab-background { + outline-color: transparent; + } + &:hover .tab-background { background-color: transparent; outline-color: transparent;