Bug 1476852 - Implement keyboard selection for multiselect tabs. r=Gijs,Jamie

To use this (Windows/Linux instructions), press Ctrl+L to give focus to the location bar. Shift+Tab to move focus backwards to the tab.
Ctrl+Left/Ctrl+Right to change which tab is focused
Ctrl+Space to add/remove a tab from the multiselection
Moving a tab has been changed from Ctrl+Left/Ctrl+Right to Ctrl+Shift+Left/Ctrl+Shift+Right, respectively.

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

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Jared Wein 2018-09-24 20:36:59 +00:00
parent ac8cdd6759
commit d783e3ca71
8 changed files with 234 additions and 26 deletions

View File

@ -1166,44 +1166,96 @@
]]></handler>
<handler event="keydown" group="system"><![CDATA[
if (event.altKey || event.shiftKey)
return;
let {altKey, shiftKey} = event;
let [accel, nonAccel] = AppConstants.platform == "macosx" ? [event.metaKey, event.ctrlKey] : [event.ctrlKey, event.metaKey];
let wrongModifiers;
if (AppConstants.platform == "macosx") {
wrongModifiers = !event.metaKey;
} else {
wrongModifiers = !event.ctrlKey || event.metaKey;
let keyComboForMove = accel && shiftKey && !altKey && !nonAccel;
let keyComboForFocus = accel && !shiftKey && !altKey && !nonAccel;
if (!keyComboForMove && !keyComboForFocus) {
return;
}
if (wrongModifiers)
return;
// Don't check if the event was already consumed because tab navigation
// should work always for better user experience.
let {visibleTabs, selectedTab} = gBrowser;
let {arrowKeysShouldWrap} = this;
let focusedTabIndex = this.ariaFocusedIndex;
if (focusedTabIndex == -1) {
focusedTabIndex = visibleTabs.indexOf(selectedTab);
}
let lastFocusedTabIndex = focusedTabIndex;
switch (event.keyCode) {
case KeyEvent.DOM_VK_UP:
gBrowser.moveTabBackward();
if (keyComboForMove) {
gBrowser.moveTabBackward();
} else {
focusedTabIndex--;
}
break;
case KeyEvent.DOM_VK_DOWN:
gBrowser.moveTabForward();
if (keyComboForMove) {
gBrowser.moveTabForward();
} else {
focusedTabIndex++;
}
break;
case KeyEvent.DOM_VK_RIGHT:
case KeyEvent.DOM_VK_LEFT:
gBrowser.moveTabOver(event);
if (keyComboForMove) {
gBrowser.moveTabOver(event);
} else {
let isRTL = Services.locale.isAppLocaleRTL;
if ((!isRTL && event.keyCode == KeyEvent.DOM_VK_RIGHT) ||
(isRTL && event.keyCode == KeyEvent.DOM_VK_LEFT)) {
focusedTabIndex++;
} else {
focusedTabIndex--;
}
}
break;
case KeyEvent.DOM_VK_HOME:
gBrowser.moveTabToStart();
if (keyComboForMove) {
gBrowser.moveTabToStart();
} else {
focusedTabIndex = 0;
}
break;
case KeyEvent.DOM_VK_END:
gBrowser.moveTabToEnd();
if (keyComboForMove) {
gBrowser.moveTabToEnd();
} else {
focusedTabIndex = visibleTabs.length - 1;
}
break;
case KeyEvent.DOM_VK_SPACE:
if (visibleTabs[lastFocusedTabIndex].multiselected) {
gBrowser.removeFromMultiSelectedTabs(visibleTabs[lastFocusedTabIndex]);
} else {
gBrowser.addToMultiSelectedTabs(visibleTabs[lastFocusedTabIndex], false);
}
break;
default:
// Consume the keydown event for the above keyboard
// shortcuts only.
return;
}
if (arrowKeysShouldWrap) {
if (focusedTabIndex >= visibleTabs.length) {
focusedTabIndex = 0;
} else if (focusedTabIndex < 0) {
focusedTabIndex = visibleTabs.length - 1;
}
} else {
focusedTabIndex = Math.min(visibleTabs.length - 1, Math.max(0, focusedTabIndex));
}
if (keyComboForFocus &&
focusedTabIndex != lastFocusedTabIndex) {
this.ariaFocusedItem = visibleTabs[focusedTabIndex];
}
event.preventDefault();
]]></handler>

View File

@ -1,12 +1,12 @@
function press(key, expectedPos) {
var originalSelectedTab = gBrowser.selectedTab;
EventUtils.synthesizeKey("VK_" + key.toUpperCase(), { accelKey: true });
EventUtils.synthesizeKey("VK_" + key.toUpperCase(), { accelKey: true, shiftKey: true });
is(gBrowser.selectedTab, originalSelectedTab,
"accel+" + key + " doesn't change which tab is selected");
"shift+accel+" + key + " doesn't change which tab is selected");
is(gBrowser.tabContainer.selectedIndex, expectedPos,
"accel+" + key + " moves the tab to the expected position");
"shift+accel+" + key + " moves the tab to the expected position");
is(document.activeElement, gBrowser.selectedTab,
"accel+" + key + " leaves the selected tab focused");
"shift+accel+" + key + " leaves the selected tab focused");
}
function test() {

View File

@ -38,6 +38,8 @@ support-files =
[browser_multiselect_tabs_reload.js]
[browser_multiselect_tabs_reorder.js]
[browser_multiselect_tabs_using_Ctrl.js]
[browser_multiselect_tabs_using_keyboard.js]
skip-if = os == 'mac' # Skipped because macOS keyboard support requires changing system settings
[browser_multiselect_tabs_using_selectedTabs.js]
[browser_multiselect_tabs_using_Shift_and_Ctrl.js]
[browser_multiselect_tabs_using_Shift.js]

View File

@ -0,0 +1,101 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const PREF_MULTISELECT_TABS = "browser.tabs.multiselect";
function synthesizeKeyAndWaitForFocus(element, keyCode, options) {
let focused = BrowserTestUtils.waitForEvent(element, "focus");
EventUtils.synthesizeKey(keyCode, options);
return focused;
}
function synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab, keyCode, options) {
let focused = TestUtils.waitForCondition(() => {
return tab.classList.contains("keyboard-focused-tab");
}, "Waiting for tab to get keyboard focus");
EventUtils.synthesizeKey(keyCode, options);
return focused;
}
add_task(async function setup() {
await SpecialPowers.pushPrefEnv({
set: [[PREF_MULTISELECT_TABS, true]],
});
let prevActiveElement = document.activeElement;
registerCleanupFunction(() => {
prevActiveElement.focus();
});
});
add_task(async function changeSelectionUsingKeyboard() {
await SpecialPowers.pushPrefEnv({
set: [[PREF_MULTISELECT_TABS, true]],
});
const tab1 = await addTab("http://mochi.test:8888/1");
const tab2 = await addTab("http://mochi.test:8888/2");
const tab3 = await addTab("http://mochi.test:8888/3");
const tab4 = await addTab("http://mochi.test:8888/4");
const tab5 = await addTab("http://mochi.test:8888/5");
await BrowserTestUtils.switchTab(gBrowser, tab3);
info("Move focus to location bar using the keyboard");
await synthesizeKeyAndWaitForFocus(gURLBar, "l", {accelKey: true});
ok(document.activeElement, "urlbar should be focused");
info("Move focus to the selected tab using the keyboard");
let identityBox = document.querySelector("#identity-box");
await synthesizeKeyAndWaitForFocus(identityBox, "VK_TAB", {shiftKey: true});
await synthesizeKeyAndWaitForFocus(tab3, "VK_TAB", {shiftKey: true});
is(document.activeElement, tab3, "Tab3 should be focused");
info("Move focus to tab 1 using the keyboard");
await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab2, "KEY_ArrowLeft", {accelKey: true});
await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab1, "KEY_ArrowLeft", {accelKey: true});
is(gBrowser.tabContainer.ariaFocusedItem, tab1, "Tab1 should be the ariaFocusedItem");
ok(!tab1.multiselected, "Tab1 shouldn't be multiselected");
info("Select tab1 using keyboard");
EventUtils.synthesizeKey("VK_SPACE", { accelKey: true });
ok(tab1.multiselected, "Tab1 should be multiselected");
info("Move focus to tab 5 using the keyboard");
await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab2, "KEY_ArrowRight", { accelKey: true });
await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab3, "KEY_ArrowRight", { accelKey: true });
await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab4, "KEY_ArrowRight", { accelKey: true });
await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab5, "KEY_ArrowRight", { accelKey: true });
ok(!tab5.multiselected, "Tab5 shouldn't be multiselected");
info("Select tab5 using keyboard");
EventUtils.synthesizeKey("VK_SPACE", { accelKey: true });
ok(tab5.multiselected, "Tab5 should be multiselected");
ok(tab1.multiselected && gBrowser._multiSelectedTabsSet.has(tab1), "Tab1 is (multi) selected");
ok(tab3.multiselected && gBrowser._multiSelectedTabsSet.has(tab3), "Tab3 is (multi) selected");
ok(tab5.multiselected && gBrowser._multiSelectedTabsSet.has(tab5), "Tab5 is (multi) selected");
is(gBrowser.multiSelectedTabsCount, 3, "Three tabs (multi) selected");
is(tab3, gBrowser.selectedTab, "Tab3 is still the selected tab");
await synthesizeKeyAndWaitForFocus(tab4, "KEY_ArrowLeft", {});
is(tab4, gBrowser.selectedTab, "Tab4 is now selected tab since tab5 had keyboard focus");
is(tab4.previousElementSibling, tab3, "tab4 should be after tab3");
is(tab4.nextElementSibling, tab5, "tab4 should be before tab5");
let tabsReordered = BrowserTestUtils.waitForCondition(() => {
return tab4.previousElementSibling == tab2 &&
tab4.nextElementSibling == tab3;
}, "tab4 should now be after tab2 and before tab3");
EventUtils.synthesizeKey("KEY_ArrowLeft", {accelKey: true, shiftKey: true});
await tabsReordered;
is(tab4.previousElementSibling, tab2, "tab4 should be after tab2");
is(tab4.nextElementSibling, tab3, "tab4 should be before tab3");
BrowserTestUtils.removeTab(tab1);
BrowserTestUtils.removeTab(tab2);
BrowserTestUtils.removeTab(tab3);
BrowserTestUtils.removeTab(tab4);
BrowserTestUtils.removeTab(tab5);
});

View File

@ -497,7 +497,8 @@ notification[value="translation"] menulist > .menulist-dropmarker {
}
}
.tabbrowser-tab:focus > .tab-stack > .tab-content {
.keyboard-focused-tab > .tab-stack > .tab-content,
.tabbrowser-tab:focus:not([aria-activedescendant]) > .tab-stack > .tab-content {
outline: 1px dotted;
outline-offset: -6px;
}

View File

@ -661,9 +661,9 @@ html|input.urlbar-input {
text-shadow: inherit;
}
.tabbrowser-tab:focus > .tab-stack > .tab-content > .tab-label-container:not([pinned]),
.tabbrowser-tab:focus > .tab-stack > .tab-content > .tab-icon-image[pinned],
.tabbrowser-tab:focus > .tab-stack > .tab-content > .tab-throbber[pinned] {
:-moz-any(.keyboard-focused-tab, .tabbrowser-tab:focus:not([aria-activedescendant])) > .tab-stack > .tab-content > .tab-label-container:not([pinned]),
:-moz-any(.keyboard-focused-tab, .tabbrowser-tab:focus:not([aria-activedescendant])) > .tab-stack > .tab-content > .tab-icon-image[pinned],
:-moz-any(.keyboard-focused-tab, .tabbrowser-tab:focus:not([aria-activedescendant])) > .tab-stack > .tab-content > .tab-throbber[pinned] {
box-shadow: var(--focus-ring-box-shadow);
}

View File

@ -690,7 +690,8 @@ html|*.urlbar-input:-moz-lwtheme::placeholder,
}
/* tabbrowser-tab focus ring */
.tabbrowser-tab:focus > .tab-stack > .tab-content {
.keyboard-focused-tab > .tab-stack > .tab-content,
.tabbrowser-tab:focus:not([aria-activedescendant]) > .tab-stack > .tab-content {
outline: 1px dotted;
outline-offset: -6px;
}

View File

@ -199,6 +199,53 @@
</setter>
</property>
<field name="ACTIVE_DESCENDANT_ID" readonly="true"><![CDATA[
"keyboard-focused-tab-" + Math.trunc(Math.random() * 1000000);
]]></field>
<property name="ariaFocusedIndex" readonly="true">
<getter>
<![CDATA[
const tabs = this.children;
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].id == this.ACTIVE_DESCENDANT_ID)
return i;
}
return -1;
]]>
</getter>
</property>
<property name="ariaFocusedItem">
<getter>
<![CDATA[
return document.getElementById(this.ACTIVE_DESCENDANT_ID);
]]>
</getter>
<setter>
<![CDATA[
let setNewItem = val && this.getIndexOfItem(val) != -1;
let clearExistingItem = this.ariaFocusedItem && (!val || setNewItem);
if (clearExistingItem) {
let ariaFocusedItem = this.ariaFocusedItem;
ariaFocusedItem.classList.remove("keyboard-focused-tab");
ariaFocusedItem.id = "";
this.selectedItem.removeAttribute("aria-activedescendant");
}
if (setNewItem) {
this.ariaFocusedItem = null;
val.id = this.ACTIVE_DESCENDANT_ID;
val.classList.add("keyboard-focused-tab");
this.selectedItem.setAttribute("aria-activedescendant", this.ACTIVE_DESCENDANT_ID);
}
return val;
]]>
</setter>
</property>
<method name="getIndexOfItem">
<parameter name="item"/>
<body>
@ -223,6 +270,8 @@
<parameter name="aWrap"/>
<body>
<![CDATA[
this.ariaFocusedItem = null;
var requestedTab = aNewTab;
while (aNewTab.hidden || aNewTab.disabled || !this._canAdvanceToTab(aNewTab)) {
aNewTab = aFallbackDir == -1 ? aNewTab.previousElementSibling : aNewTab.nextElementSibling;
@ -277,7 +326,7 @@
<parameter name="aWrap"/>
<body>
<![CDATA[
var startTab = this.selectedItem;
var startTab = this.ariaFocusedItem || this.selectedItem;
var next = startTab[(aDir == -1 ? "previous" : "next") + "ElementSibling"];
if (!next && aWrap) {
next = aDir == -1 ? this.children[this.children.length - 1] :
@ -508,6 +557,8 @@
if (this.disabled)
return;
this.parentNode.ariaFocusedItem = null;
if (this != this.parentNode.selectedItem) { // Not selected yet
let stopwatchid = this.parentNode.getAttribute("stopwatchid");
if (stopwatchid) {