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-13 17:32:49 +00:00
Родитель 44493dc39f
Коммит bea9eb3553
7 изменённых файлов: 230 добавлений и 22 удалений

Просмотреть файл

@ -1160,44 +1160,96 @@
]]></handler> ]]></handler>
<handler event="keydown" group="system"><![CDATA[ <handler event="keydown" group="system"><![CDATA[
if (event.altKey || event.shiftKey) let {altKey, shiftKey} = event;
return; let [accel, nonAccel] = AppConstants.platform == "macosx" ? [event.metaKey, event.ctrlKey] : [event.ctrlKey, event.metaKey];
let wrongModifiers; let keyComboForMove = accel && shiftKey && !altKey && !nonAccel;
if (AppConstants.platform == "macosx") { let keyComboForFocus = accel && !shiftKey && !altKey && !nonAccel;
wrongModifiers = !event.metaKey;
} else { if (!keyComboForMove && !keyComboForFocus) {
wrongModifiers = !event.ctrlKey || event.metaKey; return;
} }
if (wrongModifiers)
return;
// Don't check if the event was already consumed because tab navigation // Don't check if the event was already consumed because tab navigation
// should work always for better user experience. // 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) { switch (event.keyCode) {
case KeyEvent.DOM_VK_UP: case KeyEvent.DOM_VK_UP:
gBrowser.moveTabBackward(); if (keyComboForMove) {
gBrowser.moveTabBackward();
} else {
focusedTabIndex--;
}
break; break;
case KeyEvent.DOM_VK_DOWN: case KeyEvent.DOM_VK_DOWN:
gBrowser.moveTabForward(); if (keyComboForMove) {
gBrowser.moveTabForward();
} else {
focusedTabIndex++;
}
break; break;
case KeyEvent.DOM_VK_RIGHT: case KeyEvent.DOM_VK_RIGHT:
case KeyEvent.DOM_VK_LEFT: 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; break;
case KeyEvent.DOM_VK_HOME: case KeyEvent.DOM_VK_HOME:
gBrowser.moveTabToStart(); if (keyComboForMove) {
gBrowser.moveTabToStart();
} else {
focusedTabIndex = 0;
}
break; break;
case KeyEvent.DOM_VK_END: 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; break;
default: default:
// Consume the keydown event for the above keyboard // Consume the keydown event for the above keyboard
// shortcuts only. // shortcuts only.
return; 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(); event.preventDefault();
]]></handler> ]]></handler>

Просмотреть файл

@ -38,6 +38,8 @@ support-files =
[browser_multiselect_tabs_reload.js] [browser_multiselect_tabs_reload.js]
[browser_multiselect_tabs_reorder.js] [browser_multiselect_tabs_reorder.js]
[browser_multiselect_tabs_using_Ctrl.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_selectedTabs.js]
[browser_multiselect_tabs_using_Shift_and_Ctrl.js] [browser_multiselect_tabs_using_Shift_and_Ctrl.js]
[browser_multiselect_tabs_using_Shift.js] [browser_multiselect_tabs_using_Shift.js]

Просмотреть файл

@ -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);
});

Просмотреть файл

@ -498,7 +498,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: 1px dotted;
outline-offset: -6px; outline-offset: -6px;
} }

Просмотреть файл

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

Просмотреть файл

@ -691,7 +691,8 @@ html|*.urlbar-input:-moz-lwtheme::placeholder,
} }
/* tabbrowser-tab focus ring */ /* 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: 1px dotted;
outline-offset: -6px; outline-offset: -6px;
} }

Просмотреть файл

@ -199,6 +199,53 @@
</setter> </setter>
</property> </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"> <method name="getIndexOfItem">
<parameter name="item"/> <parameter name="item"/>
<body> <body>
@ -223,6 +270,8 @@
<parameter name="aWrap"/> <parameter name="aWrap"/>
<body> <body>
<![CDATA[ <![CDATA[
this.ariaFocusedItem = null;
var requestedTab = aNewTab; var requestedTab = aNewTab;
while (aNewTab.hidden || aNewTab.disabled || !this._canAdvanceToTab(aNewTab)) { while (aNewTab.hidden || aNewTab.disabled || !this._canAdvanceToTab(aNewTab)) {
aNewTab = aFallbackDir == -1 ? aNewTab.previousElementSibling : aNewTab.nextElementSibling; aNewTab = aFallbackDir == -1 ? aNewTab.previousElementSibling : aNewTab.nextElementSibling;
@ -277,7 +326,7 @@
<parameter name="aWrap"/> <parameter name="aWrap"/>
<body> <body>
<![CDATA[ <![CDATA[
var startTab = this.selectedItem; var startTab = this.ariaFocusedItem || this.selectedItem;
var next = startTab[(aDir == -1 ? "previous" : "next") + "ElementSibling"]; var next = startTab[(aDir == -1 ? "previous" : "next") + "ElementSibling"];
if (!next && aWrap) { if (!next && aWrap) {
next = aDir == -1 ? this.children[this.children.length - 1] : next = aDir == -1 ? this.children[this.children.length - 1] :
@ -504,6 +553,8 @@
if (this.disabled) if (this.disabled)
return; return;
this.parentNode.ariaFocusedItem = null;
if (this != this.parentNode.selectedItem) { // Not selected yet if (this != this.parentNode.selectedItem) { // Not selected yet
let stopwatchid = this.parentNode.getAttribute("stopwatchid"); let stopwatchid = this.parentNode.getAttribute("stopwatchid");
if (stopwatchid) { if (stopwatchid) {