commit b5149b6969a151a734ecea42c5e99c688f207a72
parent d393c1091ae8e9768d6ae6cb8c711b9d42183bcd
Author: Kelly Cochrane <kcochrane@mozilla.com>
Date: Tue, 30 Sep 2025 18:57:31 +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:
8 files changed, 176 insertions(+), 8 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
@@ -6569,7 +6569,7 @@
}
this.#handleTabMove(aTab, () =>
- aSplitViewWrapper.wrapper.appendChild(aTab)
+ aSplitViewWrapper.container.appendChild(aTab)
);
this.removeFromMultiSelectedTabs(aTab);
this.tabContainer._notifyBackgroundTab(aTab);
@@ -7469,7 +7469,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
@@ -900,6 +900,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,22 @@
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);
+ });
+ }
+ if (this.tabs.length < 2) {
+ this.unsplitTabs();
}
});
}
- this.#tabChangeObserver.observe(this, { childList: true });
+ this.#tabChangeObserver.observe(this.#containerElement, {
+ childList: true,
+ });
}
get splitViewId() {
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;