Bug 1377298 - improve semantics and keyboard accessibility of tour tabs UI in onboarding overlay. r=mossop, gasolin

MozReview-Commit-ID: Iay3mL6RJKF
This commit is contained in:
Yura Zenevich 2017-07-31 09:40:32 -04:00
Родитель 630e84a80d
Коммит fa4926ee67
7 изменённых файлов: 256 добавлений и 34 удалений

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

@ -153,14 +153,20 @@
#onboarding-tour-list {
margin: 40px 0 0 0;
padding: 0;
margin-inline-start: 16px;
}
#onboarding-tour-list > li {
#onboarding-tour-list .onboarding-tour-item-container {
list-style: none;
outline: none;
}
#onboarding-tour-list .onboarding-tour-item {
pointer-events: none;
display: list-item;
padding-inline-start: 49px;
padding-top: 14px;
padding-bottom: 14px;
margin-inline-start: 16px;
margin-bottom: 9px;
background-repeat: no-repeat;
background-position: left 17px top 14px;
@ -169,11 +175,11 @@
cursor: pointer;
}
#onboarding-tour-list > li:dir(rtl) {
#onboarding-tour-list .onboarding-tour-item:dir(rtl) {
background-position-x: right 17px;
}
#onboarding-tour-list > li.onboarding-complete::before {
#onboarding-tour-list .onboarding-tour-item.onboarding-complete::before {
content: url("img/icons_tour-complete.svg");
position: relative;
offset-inline-start: 3px;
@ -181,12 +187,12 @@
float: inline-start;
}
#onboarding-tour-list > li.onboarding-complete {
#onboarding-tour-list .onboarding-tour-item.onboarding-complete {
padding-inline-start: 29px;
}
#onboarding-tour-list > li.onboarding-active,
#onboarding-tour-list > li:hover {
#onboarding-tour-list .onboarding-tour-item.onboarding-active,
#onboarding-tour-list .onboarding-tour-item-container:hover .onboarding-tour-item {
color: #0A84FF;
/* With 1px transparent outline, could see a border in the high-constrast mode */
outline: 1px solid transparent;
@ -322,7 +328,8 @@
}
/* Keyboard focus specific outline */
.onboarding-tour-action-button:-moz-focusring {
.onboarding-tour-action-button:-moz-focusring,
#onboarding-tour-list .onboarding-tour-item:focus {
outline: 2px solid rgba(0,149,221,0.5);
outline-offset: 1px;
-moz-outline-radius: 2px;

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

@ -401,6 +401,7 @@ class Onboarding {
this._overlay = this._renderOverlay();
this._overlay.addEventListener("click", this);
this._overlay.addEventListener("keypress", this);
body.appendChild(this._overlay);
this._loadJS(TOUR_AGENT_JS_URI);
@ -464,16 +465,15 @@ class Onboarding {
}
}
handleEvent(evt) {
if (evt.type === "resize") {
this._window.cancelIdleCallback(this._resizeTimerId);
this._resizeTimerId =
this._window.requestIdleCallback(() => this._resizeUI());
return;
handleClick(target) {
let { id, classList } = target;
// Only containers receive pointer events in onboarding tour tab list,
// actual semantic tab is their first child.
if (classList.contains("onboarding-tour-item-container")) {
({ id, classList } = target.firstChild);
}
switch (evt.target.id) {
switch (id) {
case "onboarding-overlay-button":
case "onboarding-overlay-close-btn":
// If the clicking target is directly on the outer-most overlay,
@ -498,18 +498,82 @@ class Onboarding {
case "onboarding-tour-default-browser":
case "onboarding-tour-sync":
case "onboarding-tour-performance":
this.setToursCompleted([ evt.target.id ]);
this.setToursCompleted([ id ]);
break;
}
let classList = evt.target.classList;
if (classList.contains("onboarding-tour-item")) {
this.gotoPage(evt.target.id);
this.gotoPage(id);
// Keep focus (not visible) on current item for potential keyboard
// navigation.
target.focus();
} else if (classList.contains("onboarding-tour-action-button")) {
let activeItem = this._tourItems.find(item => item.classList.contains("onboarding-active"));
this.setToursCompleted([ activeItem.id ]);
}
}
handleKeypress(event) {
let { target, key } = event;
// Current focused item can be tab container if previous navigation was done
// via mouse.
if (target.classList.contains("onboarding-tour-item-container")) {
target = target.firstChild;
}
let targetIndex;
switch (key) {
case " ":
case "Enter":
// Assume that the handle function should be identical for keyboard
// activation if there is a click handler for the target.
if (target.classList.contains("onboarding-tour-item")) {
this.handleClick(target);
target.focus();
}
break;
case "ArrowUp":
// Go to and focus on the previous tab if it's available.
targetIndex = this._tourItems.indexOf(target);
if (targetIndex > 0) {
let previous = this._tourItems[targetIndex - 1];
this.handleClick(previous);
previous.focus();
}
event.preventDefault();
break;
case "ArrowDown":
// Go to and focus on the next tab if it's available.
targetIndex = this._tourItems.indexOf(target);
if (targetIndex > -1 && targetIndex < this._tourItems.length - 1) {
let next = this._tourItems[targetIndex + 1];
this.handleClick(next);
next.focus();
}
event.preventDefault();
break;
default:
break;
}
event.stopPropagation();
}
handleEvent(evt) {
switch (evt.type) {
case "resize":
this._window.cancelIdleCallback(this._resizeTimerId);
this._resizeTimerId =
this._window.requestIdleCallback(() => this._resizeUI());
break;
case "keypress":
this.handleKeypress(evt);
break;
case "click":
this.handleClick(evt.target);
break;
default:
break;
}
}
destroy() {
if (!this.uiInitialized) {
return;
@ -552,11 +616,13 @@ class Onboarding {
page.style.display = "none";
}
}
for (let li of this._tourItems) {
if (li.id == tourId) {
li.classList.add("onboarding-active");
for (let tab of this._tourItems) {
if (tab.id == tourId) {
tab.classList.add("onboarding-active");
tab.setAttribute("aria-selected", true);
} else {
li.classList.remove("onboarding-active");
tab.classList.remove("onboarding-active");
tab.setAttribute("aria-selected", false);
}
}
}
@ -582,9 +648,33 @@ class Onboarding {
markTourCompletionState(tourId) {
// We are doing lazy load so there might be no items.
if (this._tourItems && this._tourItems.length > 0 && this.isTourCompleted(tourId)) {
let targetItem = this._tourItems.find(item => item.id == tourId);
if (!this._tourItems || this._tourItems.length === 0) {
return;
}
let completed = this.isTourCompleted(tourId);
let targetItem = this._tourItems.find(item => item.id == tourId);
let completedTextId = `onboarding-complete-${tourId}-text`;
// Accessibility: Text version of the auxiliary information about the tour
// item completion is provided via an invisible node with an aria-label that
// the tab is pointing to via aria-described by.
let completedText = targetItem.querySelector(`#${completedTextId}`);
if (completed) {
targetItem.classList.add("onboarding-complete");
if (!completedText) {
completedText = this._window.document.createElement("span");
completedText.id = completedTextId;
completedText.setAttribute("aria-label",
this._bundle.GetStringFromName("onboarding.complete"));
targetItem.appendChild(completedText);
targetItem.setAttribute("aria-describedby", completedTextId);
}
} else {
targetItem.classList.remove("onboarding-complete");
targetItem.removeAttribute("aria-describedby");
if (completedText) {
completedText.remove();
}
}
}
@ -802,7 +892,7 @@ class Onboarding {
<div id="onboarding-overlay-dialog">
<header id="onboarding-header"></header>
<nav>
<ul id="onboarding-tour-list"></ul>
<ul id="onboarding-tour-list" role="tablist"></ul>
</nav>
<footer id="onboarding-footer">
<input type="checkbox" id="onboarding-tour-hidden-checkbox" /><label for="onboarding-tour-hidden-checkbox"></label>
@ -844,9 +934,24 @@ class Onboarding {
for (let tour of tours) {
// Create tour navigation items dynamically
let li = this._window.document.createElement("li");
li.textContent = this._bundle.GetStringFromName(tour.tourNameId);
li.id = tour.id;
li.className = "onboarding-tour-item";
// List item should have no semantics. It is just a container for an
// actual tab.
li.setAttribute("role", "presentation");
li.className = "onboarding-tour-item-container";
// Focusable but not tabbable.
li.tabIndex = -1;
let tab = this._window.document.createElement("span");
tab.id = tour.id;
tab.textContent = this._bundle.GetStringFromName(tour.tourNameId);
tab.className = "onboarding-tour-item";
tab.tabIndex = 0;
tab.setAttribute("role", "tab");
let tourPanelId = `${tour.id}-page`;
tab.setAttribute("aria-controls", tourPanelId);
li.appendChild(tab);
itemsFrag.appendChild(li);
// Dynamically create tour pages
let div = tour.getPage(this._window, this._bundle);
@ -862,12 +967,14 @@ class Onboarding {
element.dataset.l10nId, [BRAND_SHORT_NAME], 1);
}
div.id = `${tour.id}-page`;
div.id = tourPanelId;
div.classList.add("onboarding-tour-page");
div.setAttribute("role", "tabpanel");
div.setAttribute("aria-labelledby", tour.id);
div.style.display = "none";
pagesFrag.appendChild(div);
// Cache elements in arrays for later use to avoid cost of querying elements
this._tourItems.push(li);
this._tourItems.push(tab);
this._tourPages.push(div);
this.markTourCompletionState(tour.id);

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

@ -18,6 +18,10 @@ onboarding.notification-icon-tooltip-updated=See whats new!
# LOCALIZATION NOTE(onboarding.notification-close-button-tooltip): The notification close button is an icon button. This tooltip would be shown when mousing hovering on the button.
onboarding.notification-close-button-tooltip=Dismiss
# LOCALIZATION NOTE(onboarding.complete): This string is used to describe an
# onboarding tour item that is complete.
onboarding.complete=Complete
onboarding.tour-search2=Search
onboarding.tour-search.title2=Find it faster.
# LOCALIZATION NOTE (onboarding.tour-search.description2): If Amazon is not part

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

@ -3,6 +3,7 @@ support-files =
head.js
[browser_onboarding_accessibility.js]
[browser_onboarding_keyboard.js]
[browser_onboarding_notification.js]
[browser_onboarding_notification_2.js]
[browser_onboarding_notification_3.js]

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

@ -0,0 +1,62 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
function assertTourList(browser, args) {
return ContentTask.spawn(browser, args, ({ tourId, focusedId }) => {
let doc = content.document;
let items = [...doc.querySelectorAll(".onboarding-tour-item")];
items.forEach(item => is(item.getAttribute("aria-selected"),
item.id === tourId ? "true" : "false",
"Active item should have aria-selected set to true and inactive to false"));
let focused = doc.getElementById(focusedId);
is(focused, doc.activeElement, `Focus should be set on ${focusedId}`);
});
}
const TEST_DATA = [
{ key: "VK_DOWN", expected: { tourId: TOUR_IDs[1], focusedId: TOUR_IDs[1] }},
{ key: "VK_DOWN", expected: { tourId: TOUR_IDs[2], focusedId: TOUR_IDs[2] }},
{ key: "VK_DOWN", expected: { tourId: TOUR_IDs[3], focusedId: TOUR_IDs[3] }},
{ key: "VK_DOWN", expected: { tourId: TOUR_IDs[4], focusedId: TOUR_IDs[4] }},
{ key: "VK_DOWN", expected: { tourId: TOUR_IDs[5], focusedId: TOUR_IDs[5] }},
{ key: "VK_UP", expected: { tourId: TOUR_IDs[4], focusedId: TOUR_IDs[4] }},
{ key: "VK_UP", expected: { tourId: TOUR_IDs[3], focusedId: TOUR_IDs[3] }},
{ key: "VK_TAB", expected: { tourId: TOUR_IDs[3], focusedId: TOUR_IDs[4] }},
{ key: "VK_TAB", expected: { tourId: TOUR_IDs[3], focusedId: TOUR_IDs[5] }},
{ key: "VK_RETURN", expected: { tourId: TOUR_IDs[5], focusedId: TOUR_IDs[5] }},
{ key: "VK_TAB", options: { shiftKey: true }, expected: { tourId: TOUR_IDs[5], focusedId: TOUR_IDs[4] }},
{ key: "VK_TAB", options: { shiftKey: true }, expected: { tourId: TOUR_IDs[5], focusedId: TOUR_IDs[3] }},
// VK_SPACE does not work well with EventUtils#synthesizeKey use " " instead
{ key: " ", expected: { tourId: TOUR_IDs[3], focusedId: TOUR_IDs[3] }}
];
add_task(async function test_tour_list_keyboard_navigation() {
resetOnboardingDefaultState();
info("Display onboarding overlay on the home page");
let tab = await openTab(ABOUT_HOME_URL);
await promiseOnboardingOverlayLoaded(tab.linkedBrowser);
await BrowserTestUtils.synthesizeMouseAtCenter("#onboarding-overlay-button",
{}, tab.linkedBrowser);
await promiseOnboardingOverlayOpened(tab.linkedBrowser);
info("Checking overall overlay tablist semantics");
await assertOverlaySemantics(tab.linkedBrowser);
info("Set initial focus on the currently active tab");
await ContentTask.spawn(tab.linkedBrowser, {}, () =>
content.document.querySelector(".onboarding-active").focus());
await assertTourList(tab.linkedBrowser,
{ tourId: TOUR_IDs[0], focusedId: TOUR_IDs[0] });
for (let { key, options = {}, expected } of TEST_DATA) {
info(`Pressing ${key} to select ${expected.tourId} and have focus on ${expected.focusedId}`);
await BrowserTestUtils.synthesizeKey(key, options, tab.linkedBrowser);
await assertTourList(tab.linkedBrowser, expected);
}
await BrowserTestUtils.removeTab(tab);
});

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

@ -18,13 +18,22 @@ function assertOnboardingDestroyed(browser) {
});
}
function assertTourCompletedStyle(tourId, expectComplete, browser) {
function assertTourCompleted(tourId, expectComplete, browser) {
return ContentTask.spawn(browser, { tourId, expectComplete }, function(args) {
let item = content.document.querySelector(`#${args.tourId}.onboarding-tour-item`);
let completedTextId = `onboarding-complete-${args.tourId}-text`;
let completedText = item.querySelector(`#${completedTextId}`);
if (args.expectComplete) {
ok(item.classList.contains("onboarding-complete"), `Should set the complete #${args.tourId} tour with the complete style`);
ok(completedText, "Text label should be present for a completed item");
is(completedText.id, completedTextId, "Text label node should have a unique id");
ok(completedText.getAttribute("aria-label"), "Text label node should have an aria-label attribute set");
is(item.getAttribute("aria-describedby"), completedTextId,
"Completed item should have aria-describedby attribute set to text label node's id");
} else {
ok(!item.classList.contains("onboarding-complete"), `Should not set the incomplete #${args.tourId} tour with the complete style`);
ok(!completedText, "Text label should not be present for an incomplete item");
ok(!item.hasAttribute("aria-describedby"), "Incomplete item should not have aria-describedby attribute set");
}
});
}
@ -79,8 +88,9 @@ add_task(async function test_click_action_button_to_set_tour_completed() {
for (let i = tabs.length - 1; i >= 0; --i) {
let tab = tabs[i];
await assertOverlaySemantics(tab.linkedBrowser);
for (let id of tourIds) {
await assertTourCompletedStyle(id, id == completedTourId, tab.linkedBrowser);
await assertTourCompleted(id, id == completedTourId, tab.linkedBrowser);
}
await BrowserTestUtils.removeTab(tab);
}
@ -106,8 +116,9 @@ add_task(async function test_set_right_tour_completed_style_on_overlay() {
for (let i = tabs.length - 1; i >= 0; --i) {
let tab = tabs[i];
await assertOverlaySemantics(tab.linkedBrowser);
for (let j = 0; j < tourIds.length; ++j) {
await assertTourCompletedStyle(tourIds[j], j % 2 == 0, tab.linkedBrowser);
await assertTourCompleted(tourIds[j], j % 2 == 0, tab.linkedBrowser);
}
await BrowserTestUtils.removeTab(tab);
}

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

@ -194,3 +194,33 @@ function waitUntilWindowIdle(browser) {
function skipMuteNotificationOnFirstSession() {
Preferences.set("browser.onboarding.notification.mute-duration-on-first-session-ms", 0);
}
function assertOverlaySemantics(browser) {
return ContentTask.spawn(browser, {}, function() {
let doc = content.document;
info("Checking the tablist container");
is(doc.getElementById("onboarding-tour-list").getAttribute("role"), "tablist",
"Tour list should have a tablist role argument set");
info("Checking each tour item that represents the tab");
let items = [...doc.querySelectorAll(".onboarding-tour-item")];
items.forEach(item => {
is(item.parentNode.getAttribute("role"), "presentation",
"Parent should have no semantic value");
is(item.getAttribute("aria-selected"),
item.classList.contains("onboarding-active") ? "true" : "false",
"Active item should have aria-selected set to true and inactive to false");
is(item.tabIndex, "0", "Item tab index must be set for keyboard accessibility");
is(item.getAttribute("role"), "tab", "Item should have a tab role argument set");
let tourPanelId = `${item.id}-page`;
is(item.getAttribute("aria-controls"), tourPanelId,
"Item should have aria-controls attribute point to its tabpanel");
let panel = doc.getElementById(tourPanelId);
is(panel.getAttribute("role"), "tabpanel",
"Tour panel should have a tabpanel role argument set");
is(panel.getAttribute("aria-labelledby"), item.id,
"Tour panel should have aria-labelledby attribute point to its tab");
});
});
}