Bug 1575749: Implement toolbar navigation by typed character. r=Gijs

When focused on a toolbar button, users can now type the first (or first few) characters of another button's name to jump directly to that button.
The search characters are cleared after 1 second or if a non-character key is pressed.
This is similar to the typed character navigation implemented for HTML select controls.

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

--HG--
extra : moz-landing-system : lando
This commit is contained in:
James Teh 2019-08-30 03:36:53 +00:00
Родитель f03bae30af
Коммит 681ed6270e
2 изменённых файлов: 146 добавлений и 0 удалений

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

@ -19,11 +19,17 @@
* these gets focus, it redirects focus to the appropriate button. This avoids
* the need to continually manage the tabindex of toolbar buttons in response to
* toolbarchanges.
* In addition to linear navigation with tab and arrows, users can also type
* the first (or first few) characters of a button's name to jump directly to
* that button.
*/
ToolbarKeyboardNavigator = {
// Toolbars we want to be keyboard navigable.
kToolbars: [CustomizableUI.AREA_NAVBAR, CustomizableUI.AREA_BOOKMARKS],
// Delay (in ms) after which to clear any search text typed by the user if
// the user hasn't typed anything further.
kSearchClearTimeout: 1000,
_isButton(aElem) {
return (
@ -194,6 +200,20 @@ ToolbarKeyboardNavigator = {
_onKeyDown(aEvent) {
let focus = document.activeElement;
if (
aEvent.key != " " &&
aEvent.key.length == 1 &&
this._isButton(focus) &&
// Don't handle characters if the user is focused in a panel anchored
// to the toolbar.
!focus.closest("panel")
) {
this._onSearchChar(aEvent.currentTarget, aEvent.key);
return;
}
// Anything that doesn't trigger search should clear the search.
this._clearSearch();
if (
aEvent.altKey ||
aEvent.controlKey ||
@ -219,6 +239,83 @@ ToolbarKeyboardNavigator = {
aEvent.preventDefault();
},
_clearSearch() {
this._searchText = "";
if (this._clearSearchTimeout) {
clearTimeout(this._clearSearchTimeout);
this._clearSearchTimeout = null;
}
},
_onSearchChar(aToolbar, aChar) {
if (this._clearSearchTimeout) {
// The user just typed a character, so reset the timer.
clearTimeout(this._clearSearchTimeout);
}
// Convert to lower case so we can do case insensitive searches.
let char = aChar.toLowerCase();
// If the user has only typed a single character and they type the same
// character again, they want to move to the next item starting with that
// same character. Effectively, it's as if there was no existing search.
// In that case, we just leave this._searchText alone.
if (!this._searchText) {
this._searchText = char;
} else if (this._searchText != char) {
this._searchText += char;
}
// Clear the search if the user doesn't type anything more within the timeout.
this._clearSearchTimeout = setTimeout(
this._clearSearch.bind(this),
this.kSearchClearTimeout
);
let oldFocus = document.activeElement;
let walker = this._getWalker(aToolbar);
// Search forward after the current control.
walker.currentNode = oldFocus;
for (
let newFocus = walker.nextNode();
newFocus;
newFocus = walker.nextNode()
) {
if (this._doesSearchMatch(newFocus)) {
this._focusButton(newFocus);
return;
}
}
// No match, so search from the start until the current control.
walker.currentNode = walker.root;
for (
let newFocus = walker.firstChild();
newFocus && newFocus != oldFocus;
newFocus = walker.nextNode()
) {
if (this._doesSearchMatch(newFocus)) {
this._focusButton(newFocus);
return;
}
}
},
_doesSearchMatch(aElem) {
if (!this._isButton(aElem)) {
return false;
}
for (let attrib of ["aria-label", "label", "tooltiptext"]) {
let label = aElem.getAttribute(attrib);
if (!label) {
continue;
}
// Convert to lower case so we do a case insensitive comparison.
// (this._searchText is already lower case.)
label = label.toLowerCase();
if (label.startsWith(this._searchText)) {
return true;
}
}
return false;
},
_onKeyPress(aEvent) {
let focus = document.activeElement;
if (

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

@ -403,3 +403,52 @@ add_task(async function testArrowKeyForTPIconContainerandIdentityBox() {
);
});
});
// Test navigation by typed characters.
add_task(async function testCharacterNavigation() {
await BrowserTestUtils.withNewTab("https://example.com", async function() {
await waitUntilReloadEnabled();
startFromUrlBar();
await expectFocusAfterKey("Tab", "pageActionButton");
await expectFocusAfterKey("h", "home-button");
// There's no button starting with "hs", so pressing s should do nothing.
EventUtils.synthesizeKey("s");
is(
document.activeElement.id,
"home-button",
"home-button still focused after s pressed"
);
// Escape should reset the search.
EventUtils.synthesizeKey("KEY_Escape");
// Now that the search is reset, pressing s should focus Save to Pocket.
await expectFocusAfterKey("s", "pocket-button");
// Pressing i makes the search "si", so it should focus Sidebars.
await expectFocusAfterKey("i", "sidebar-button");
// Reset the search.
EventUtils.synthesizeKey("KEY_Escape");
await expectFocusAfterKey("s", "pocket-button");
// Pressing s again should find the next button starting with s: Sidebars.
await expectFocusAfterKey("s", "sidebar-button");
});
});
// Test that toolbar character navigation doesn't trigger in PanelMultiView for
// a panel anchored to the toolbar.
// We do this by opening the Library menu and ensuring that pressing s
// does nothing.
// This test should be removed if PanelMultiView implements character
// navigation.
add_task(async function testCharacterInPanelMultiView() {
let button = document.getElementById("library-button");
forceFocus(button);
let view = document.getElementById("appMenu-libraryView");
let focused = BrowserTestUtils.waitForEvent(view, "focus", true);
EventUtils.synthesizeKey(" ");
let focusEvt = await focused;
ok(true, "Focus inside Library menu after toolbar button pressed");
EventUtils.synthesizeKey("s");
is(document.activeElement, focusEvt.target, "s inside panel does nothing");
let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
view.closest("panel").hidePopup();
await hidden;
});