Commit 8a7cd9b4 authored by John Lee's avatar John Lee Committed by Commit Bot

Settings WebUI: Allow users to use arrow keys to navigate menu

This CL adds a new component <cr-menu-selector> that takes in a set of
elements that have a role of 'menuitem' and manages focus between the
items as the user uses arrow, Home, and End keys. The first menuitem
is the only menuitem that is focusable using the Tab key, allowing users
to easily skip over the menu to the main content area of a page.

This follows the accessibility guidelines set for navigation drawers.
This element will also be used on the History page's menu.

Bug: 1110405
Change-Id: I1d644959412d5c19f324d75336bd0e9a08ce7ca1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2436706Reviewed-by: default avatardpapad <dpapad@chromium.org>
Commit-Queue: John Lee <johntlee@chromium.org>
Cr-Commit-Position: refs/heads/master@{#814293}
parent 9383984a
......@@ -95,49 +95,58 @@
}
}
</style>
<div role="navigation">
<cr-menu-selector>
<iron-selector id="topMenu" selectable="a:not(#extensionsLink)"
attr-for-selected="href" on-iron-activate="onSelectorActivate_"
role="navigation" on-click="onLinkClick_">
<a id="people" href="/people" hidden="[[!pageVisibility.people]]">
on-click="onLinkClick_">
<a role="menuitem"
id="people" href="/people" hidden="[[!pageVisibility.people]]">
<iron-icon icon="cr:person"></iron-icon>
$i18n{peoplePageTitle}
</a>
<a id="autofill" href="/autofill"
<a role="menuitem" id="autofill" href="/autofill"
hidden="[[!pageVisibility.autofill]]">
<iron-icon icon="settings:assignment"></iron-icon>
$i18n{autofillPageTitle}
</a>
<a href="/safetyCheck" hidden="[[!pageVisibility.safetyCheck]]"
<a role="menuitem" href="/safetyCheck"
hidden="[[!pageVisibility.safetyCheck]]"
id="safetyCheck">
<iron-icon icon="settings20:safety-check"></iron-icon>
$i18n{safetyCheckSectionTitle}
</a>
<a href="/privacy" hidden="[[!pageVisibility.privacy]]">
<a role="menuitem" href="/privacy"
hidden="[[!pageVisibility.privacy]]">
<iron-icon icon="cr:security"></iron-icon>
$i18n{privacyPageTitle}
</a>
<a id="appearance" href="/appearance"
<a role="menuitem" id="appearance" href="/appearance"
hidden="[[!pageVisibility.appearance]]">
<iron-icon icon="settings:palette"></iron-icon>
$i18n{appearancePageTitle}
</a>
<a href="/search">
<a role="menuitem" href="/search">
<iron-icon icon="cr:search"></iron-icon>
$i18n{searchPageTitle}
</a>
<if expr="not chromeos">
<a id="defaultBrowser" href="/defaultBrowser"
<if expr="not chromeos">
<a role="menuitem" id="defaultBrowser"
href="/defaultBrowser"
hidden="[[!pageVisibility.defaultBrowser]]">
<iron-icon icon="settings:web"></iron-icon>
$i18n{defaultBrowser}
</a>
</if>
<a id="onStartup" href="/onStartup"
</if>
<a role="menuitem" id="onStartup" href="/onStartup"
hidden="[[!pageVisibility.onStartup]]">
<iron-icon icon="settings:power-settings-new"></iron-icon>
$i18n{onStartup}
</a>
<cr-button id="advancedButton"
<cr-button
role="menuitem" tabindex="-1"
id="advancedButton"
aria-expanded$="[[boolToString_(advancedOpened)]]"
on-click="onAdvancedButtonToggle_"
hidden="[[!pageVisibility.advancedSettings]]">
......@@ -148,41 +157,54 @@
hidden="[[!pageVisibility.advancedSettings]]">
<iron-selector id="subMenu" selectable="a" attr-for-selected="href"
role="navigation" on-click="onLinkClick_">
<a href="/languages">
<a role="menuitem" href="/languages"
disabled$="[[!advancedOpened]]">
<iron-icon icon="settings:language"></iron-icon>
$i18n{languagesPageTitle}
</a>
<a href="/downloads">
<a role="menuitem" href="/downloads"
disabled$="[[!advancedOpened]]">
<iron-icon icon="cr:file-download"></iron-icon>
$i18n{downloadsPageTitle}
</a>
<a href="/printing" hidden="[[!pageVisibility.printing]]">
<a role="menuitem" href="/printing"
disabled$="[[!advancedOpened]]"
hidden="[[!pageVisibility.printing]]">
<iron-icon icon="cr:print"></iron-icon>
$i18n{printingPageTitle}
</a>
<a href="/accessibility">
<a role="menuitem" href="/accessibility"
disabled$="[[!advancedOpened]]">
<iron-icon icon="settings:accessibility"></iron-icon>
$i18n{a11yPageTitle}
</a>
<if expr="not chromeos">
<a href="/system">
<if expr="not chromeos">
<a role="menuitem" href="/system"
disabled$="[[!advancedOpened]]">
<iron-icon icon="settings:build"></iron-icon>
$i18n{systemPageTitle}
</a>
</if>
<a id="reset" href="/reset" hidden="[[!pageVisibility.reset]]">
</if>
<a role="menuitem" id="reset" href="/reset"
disabled$="[[!advancedOpened]]"
hidden="[[!pageVisibility.reset]]">
<iron-icon icon="settings:restore"></iron-icon>
$i18n{resetPageTitle}
</a>
</iron-selector>
</iron-collapse>
<div id="menuSeparator"></div>
<a id="extensionsLink" href="chrome://extensions" target="_blank"
<a role="menuitem" id="extensionsLink"
href="chrome://extensions" target="_blank"
hidden="[[!pageVisibility.extensions]]"
on-click="onExtensionsLinkClick_"
title="$i18n{extensionsLinkTooltip}">
<span>$i18n{extensionsPageTitle}</span>
<div class="cr-icon icon-external"></div>
</a>
<a id="about-menu" href="/help">$i18n{aboutPageTitle}</a>
<a role="menuitem" id="about-menu" href="/help">
$i18n{aboutPageTitle}
</a>
</iron-selector>
</cr-menu-selector>
</div>
\ No newline at end of file
......@@ -8,6 +8,7 @@
*/
import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import 'chrome://resources/cr_elements/cr_icons_css.m.js';
import 'chrome://resources/cr_elements/cr_menu_selector/cr_menu_selector.js';
import 'chrome://resources/cr_elements/icons.m.js';
import 'chrome://resources/polymer/v3_0/iron-collapse/iron-collapse.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
......
......@@ -170,3 +170,16 @@ var CrElementsGridFocusTest = class extends CrElementsV3FocusTest {
TEST_F('CrElementsGridFocusTest', 'All', function() {
mocha.run();
});
// eslint-disable-next-line no-var
var CrElementsMenuSelectorFocusTest = class extends CrElementsV3FocusTest {
/** @override */
get browsePreload() {
return 'chrome://test?module=cr_elements/cr_menu_selector_focus_test.js';
}
};
TEST_F('CrElementsMenuSelectorFocusTest', 'All', function() {
mocha.run();
});
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {CrMenuSelector} from 'chrome://resources/cr_elements/cr_menu_selector/cr_menu_selector.js';
import {getDeepActiveElement} from 'chrome://resources/js/util.m.js';
import {keyDownOn} from 'chrome://resources/polymer/v3_0/iron-test-helpers/mock-interactions.js';
import {assertEquals, assertFalse} from '../chai_assert.js';
import {eventToPromise} from '../test_util.m.js';
suite('CrMenuSelectorFocusTest', () => {
/** @type {!CrMenuSelector} */
let element;
setup(() => {
document.body.innerHTML = '';
element = document.createElement('cr-menu-selector');
// Slot some menu items.
for (let i = 0; i < 3; i++) {
const item = document.createElement('button');
item.setAttribute('role', 'menuitem');
item.id = i;
element.appendChild(item);
}
document.body.appendChild(element);
});
test('ArrowKeysMoveFocus', () => {
// The focus is not in any of the children yet, so the first arrow down
// should focus the first menu item.
keyDownOn(element.children[0], 0, [], 'ArrowDown');
assertEquals(element.children[0], getDeepActiveElement());
keyDownOn(element.children[0], 0, [], 'ArrowDown');
assertEquals(element.children[1], getDeepActiveElement());
keyDownOn(element.children[1], 0, [], 'ArrowUp');
assertEquals(element.children[0], getDeepActiveElement());
});
test('HomeMovesFocusToFirstElement', () => {
element.children[0].focus();
keyDownOn(element.children[0], 0, [], 'ArrowDown');
keyDownOn(element.children[2], 0, [], 'Home');
assertEquals(element.children[0], getDeepActiveElement());
});
test('EndMovesFocusToFirstElement', () => {
element.children[0].focus();
keyDownOn(element.children[2], 0, [], 'End');
assertEquals(element.children[2], getDeepActiveElement());
});
test('WrapsFocusWhenReachingEnds', () => {
element.children[0].focus();
keyDownOn(element.children[0], 0, [], 'ArrowUp');
assertEquals(element.children[2], getDeepActiveElement());
keyDownOn(element.children[0], 0, [], 'ArrowDown');
assertEquals(element.children[0], getDeepActiveElement());
});
test('SkipsDisabledElements', () => {
element.children[0].focus();
element.children[1].disabled = true;
keyDownOn(element.children[0], 0, [], 'ArrowDown');
assertEquals(element.children[2], getDeepActiveElement());
});
test('SkipsHiddenElements', () => {
element.children[0].focus();
element.children[1].hidden = true;
keyDownOn(element.children[0], 0, [], 'ArrowDown');
assertEquals(element.children[2], getDeepActiveElement());
});
test('SkipsNonMenuItems', () => {
element.children[0].focus();
element.children[1].setAttribute('role', 'presentation');
keyDownOn(element.children[0], 0, [], 'ArrowDown');
assertEquals(element.children[2], getDeepActiveElement());
});
test('FocusingIntoItAlwaysFocusesFirstItem', () => {
const outsideElement = document.createElement('button');
document.body.appendChild(outsideElement);
outsideElement.focus();
element.children[2].focus();
assertEquals(element.children[0], getDeepActiveElement());
});
test('TabMovesFocusToLastElement', async () => {
element.children[0].focus();
const tabEventPromise = eventToPromise('keydown', element.children[0]);
keyDownOn(element.children[0], 0, [], 'Tab');
const tabEvent = await tabEventPromise;
assertEquals(element.children[2], getDeepActiveElement());
assertFalse(tabEvent.defaultPrevented);
});
test('ShiftTabMovesFocusToFirstElement', async () => {
// First, mock focus on last element.
element.children[0].focus();
keyDownOn(element.children[0], 0, [], 'End');
const shiftTabEventPromise = eventToPromise('keydown', element.children[2]);
keyDownOn(element.children[2], 0, ['shift'], 'Tab');
const shiftTabEvent = await shiftTabEventPromise;
assertEquals(element.children[0], getDeepActiveElement());
assertFalse(shiftTabEvent.defaultPrevented);
});
});
\ No newline at end of file
......@@ -89,6 +89,7 @@ preprocess_grit("preprocess_generated") {
"cr_elements/cr_container_shadow_behavior.m.js",
"cr_elements/action_link_css.m.js",
"cr_elements/cr_grid/cr_grid.js",
"cr_elements/cr_menu_selector/cr_menu_selector.js",
"cr_elements/cr_view_manager/cr_view_manager.m.js",
"cr_elements/cr_toolbar/cr_toolbar_selection_overlay.m.js",
"cr_elements/cr_toolbar/cr_toolbar_search_field.m.js",
......
......@@ -22,6 +22,7 @@ group("closure_compile") {
"cr_input:closure_compile",
"cr_link_row:closure_compile",
"cr_lottie:closure_compile",
"cr_menu_selector:closure_compile",
"cr_profile_avatar_selector:closure_compile",
"cr_radio_button:closure_compile",
"cr_radio_group:closure_compile",
......@@ -188,6 +189,7 @@ group("polymer3_elements") {
"cr_lazy_render:cr_lazy_render_module",
"cr_link_row:cr_link_row_module",
"cr_lottie:cr_lottie_module",
"cr_menu_selector:web_components",
"cr_profile_avatar_selector:polymer3_elements",
"cr_radio_button:polymer3_elements",
"cr_radio_group:cr_radio_group_module",
......
# Copyright 2020 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import("//third_party/closure_compiler/compile_js.gni")
import("//tools/polymer/html_to_js.gni")
html_to_js("web_components") {
js_files = [ "cr_menu_selector.js" ]
}
js_type_check("closure_compile") {
is_polymer3 = true
deps = [ ":cr_menu_selector" ]
}
js_library("cr_menu_selector") {
deps = [ "//ui/webui/resources/js:assert.m" ]
}
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert.m.js';
/** @extends {HTMLElement} */
export class CrMenuSelector extends HTMLElement {
static get is() {
return 'cr-menu-selector';
}
constructor() {
super();
this.addEventListener(
'focusin', e => this.onFocusin_(/** @type {!FocusEvent} */ (e)));
this.addEventListener(
'keydown', e => this.onKeydown_(/** @type {!KeyboardEvent} */ (e)));
}
connectedCallback() {
this.setAttribute('role', 'menu');
}
/**
* @return {!Array<!HTMLElement>}
* @private
*/
getItems_() {
return /** @type {!Array<!HTMLElement>} */ (
Array.from(this.querySelectorAll(
'[role=menuitem]:not([disabled]):not([hidden])')));
}
/**
* @param {!FocusEvent} e
* @private
*/
onFocusin_(e) {
// If the focus is coming in from a relatedTarget that is not within this
// menu, move the focus to the first menu item. This ensures that the first
// menu item is always the first focused item when focusing into the menu.
// The relatedTarget property is the last focused item before focus was
// moved.
if (!this.contains(/** @type {!HTMLElement} */ (e.relatedTarget))) {
this.getItems_()[0].focus();
}
}
/**
* @param {!KeyboardEvent} event
* @private
*/
onKeydown_(event) {
const items = this.getItems_();
assert(items.length >= 1);
const currentFocusedIndex = items.indexOf(
/** @type {!HTMLElement} */ (this.querySelector(':focus')));
let newFocusedIndex = currentFocusedIndex;
switch (event.key) {
case 'Tab':
if (event.shiftKey) {
// If pressing Shift+Tab, immediately focus the first element so that
// when the event is finished processing, the browser automatically
// focuses the previous focusable element outside of the menu.
items[0].focus();
} else {
// If pressing Tab, immediately focus the last element so that when
// the event is finished processing, the browser automatically focuses
// the next focusable element outside of the menu.
items[items.length - 1].focus();
}
return;
case 'ArrowDown':
newFocusedIndex = (currentFocusedIndex + 1) % items.length;
break;
case 'ArrowUp':
newFocusedIndex =
(currentFocusedIndex + items.length - 1) % items.length;
break;
case 'Home':
newFocusedIndex = 0;
break;
case 'End':
newFocusedIndex = items.length - 1;
break;
}
if (newFocusedIndex === currentFocusedIndex) {
return;
}
event.preventDefault();
items[newFocusedIndex].focus();
}
}
customElements.define(CrMenuSelector.is, CrMenuSelector);
\ No newline at end of file
......@@ -61,6 +61,10 @@
file="${root_gen_dir}/ui/webui/resources/cr_elements/cr_input/cr_input_style_css.m.js"
use_base_dir="false"
type="BINDATA" />
<include name="IDR_CR_ELEMENTS_CR_MENU_SELECTOR_JS"
file="${root_gen_dir}/ui/webui/resources/cr_elements/cr_menu_selector/cr_menu_selector.js"
use_base_dir="false"
type="BINDATA" />
<include name="IDR_CR_ELEMENTS_CR_PAGE_HOST_STYLE_CSS_M_JS"
file="${root_gen_dir}/ui/webui/resources/cr_elements/cr_page_host_style_css.m.js"
use_base_dir="false"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment