зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
44493dc39f
Коммит
bea9eb3553
|
@ -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) {
|
||||||
|
|
Загрузка…
Ссылка в новой задаче