зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
630e84a80d
Коммит
fa4926ee67
|
@ -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 what’s 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");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче