зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1377276 - add modal dialog semantics and better accessibility for onboarding overlay dialog. r=mossop, gasolin, rexboy
MozReview-Commit-ID: 9xyhn7jLJqD
This commit is contained in:
Родитель
31b64ac4d9
Коммит
4faa8c2b59
|
@ -20,6 +20,7 @@ const BRAND_SHORT_NAME = Services.strings
|
|||
.createBundle("chrome://branding/locale/brand.properties")
|
||||
.GetStringFromName("brandShortName");
|
||||
const PROMPT_COUNT_PREF = "browser.onboarding.notification.prompt-count";
|
||||
const ONBOARDING_DIALOG_ID = "onboarding-overlay-dialog";
|
||||
|
||||
/**
|
||||
* Add any number of tours, key is the tourId, value should follow the format below
|
||||
|
@ -368,6 +369,7 @@ class Onboarding {
|
|||
let { body } = this._window.document;
|
||||
this._overlayIcon = this._renderOverlayButton();
|
||||
this._overlayIcon.addEventListener("click", this);
|
||||
this._overlayIcon.addEventListener("keypress", this);
|
||||
body.insertBefore(this._overlayIcon, body.firstChild);
|
||||
|
||||
this._overlay = this._renderOverlay();
|
||||
|
@ -436,6 +438,15 @@ class Onboarding {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tour that should be selected. It is either a first tour that was not
|
||||
* yet complete or the first one in the tab list.
|
||||
*/
|
||||
get selectedTour() {
|
||||
return this._tours.find(tour => !this.isTourCompleted(tour.id)) ||
|
||||
this._tours[0];
|
||||
}
|
||||
|
||||
handleClick(target) {
|
||||
let { id, classList } = target;
|
||||
// Only containers receive pointer events in onboarding tour tab list,
|
||||
|
@ -452,8 +463,7 @@ class Onboarding {
|
|||
// Let's toggle the overlay.
|
||||
case "onboarding-overlay":
|
||||
this.toggleOverlay();
|
||||
let selectedTour = this._tours.find(tour => !this.isTourCompleted(tour.id)) || this._tours[0];
|
||||
this.gotoPage(selectedTour.id);
|
||||
this.gotoPage(this.selectedTour.id);
|
||||
break;
|
||||
case "onboarding-notification-close-btn":
|
||||
this.hideNotification();
|
||||
|
@ -477,8 +487,46 @@ class Onboarding {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap keyboard focus within the dialog and focus on first element after last
|
||||
* when moving forward or last element after first when moving backwards. Do
|
||||
* nothing if focus is moving in the middle of the list of dialog's focusable
|
||||
* elements.
|
||||
*
|
||||
* @param {DOMNode} current currently focused element
|
||||
* @param {Boolean} back direction
|
||||
* @return {DOMNode} newly focused element if any
|
||||
*/
|
||||
wrapMoveFocus(current, back) {
|
||||
let elms = [...this._dialog.querySelectorAll(
|
||||
`button, input[type="checkbox"], input[type="email"], [tabindex="0"]`)];
|
||||
let next;
|
||||
if (back) {
|
||||
if (elms.indexOf(current) === 0) {
|
||||
next = elms[elms.length - 1];
|
||||
next.focus();
|
||||
}
|
||||
} else if (elms.indexOf(current) === elms.length - 1) {
|
||||
next = elms[0];
|
||||
next.focus();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
handleKeypress(event) {
|
||||
let { target, key } = event;
|
||||
let { target, key, shiftKey } = event;
|
||||
|
||||
if (target === this._overlayIcon) {
|
||||
if ([" ", "Enter"].includes(key)) {
|
||||
// Remember that the dialog was opened with a keyboard.
|
||||
this._overlayIcon.dataset.keyboardFocus = true;
|
||||
this.handleClick(target);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Current focused item can be tab container if previous navigation was done
|
||||
// via mouse.
|
||||
if (target.classList.contains("onboarding-tour-item-container")) {
|
||||
|
@ -515,6 +563,16 @@ class Onboarding {
|
|||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "Escape":
|
||||
this.toggleOverlay();
|
||||
break;
|
||||
case "Tab":
|
||||
let next = this.wrapMoveFocus(target, shiftKey);
|
||||
// If focus was wrapped, prevent Tab key default action.
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -564,6 +622,7 @@ class Onboarding {
|
|||
|
||||
this.hideNotification();
|
||||
this._overlay.classList.toggle("onboarding-opened");
|
||||
this.toggleModal(this._overlay.classList.contains("onboarding-opened"));
|
||||
|
||||
let hiddenCheckbox = this._window.document.getElementById("onboarding-tour-hidden-checkbox");
|
||||
if (hiddenCheckbox.checked) {
|
||||
|
@ -571,6 +630,41 @@ class Onboarding {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set modal dialog state and properties for accessibility purposes.
|
||||
* @param {Boolean} opened whether the dialog is opened or closed.
|
||||
*/
|
||||
toggleModal(opened) {
|
||||
let { document: doc } = this._window;
|
||||
if (opened) {
|
||||
// Set aria-hidden to true for the rest of the document.
|
||||
[...doc.body.children].forEach(
|
||||
child => child.id !== "onboarding-overlay" &&
|
||||
child.setAttribute("aria-hidden", true));
|
||||
// When dialog is opened with the keyboard, focus on the selected or
|
||||
// first tour item.
|
||||
if (this._overlayIcon.dataset.keyboardFocus) {
|
||||
doc.getElementById(this.selectedTour.id).focus();
|
||||
} else {
|
||||
// When dialog is opened with mouse, focus on the dialog itself to avoid
|
||||
// visible keyboard focus styling.
|
||||
this._dialog.focus();
|
||||
}
|
||||
} else {
|
||||
// Remove all set aria-hidden attributes.
|
||||
[...doc.body.children].forEach(
|
||||
child => child.removeAttribute("aria-hidden"));
|
||||
// If dialog was opened with a keyboard, set the focus back on the overlay
|
||||
// button.
|
||||
if (this._overlayIcon.dataset.keyboardFocus) {
|
||||
delete this._overlayIcon.dataset.keyboardFocus;
|
||||
this._overlayIcon.focus();
|
||||
} else {
|
||||
this._window.document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gotoPage(tourId) {
|
||||
let targetPageId = `${tourId}-page`;
|
||||
for (let page of this._tourPages) {
|
||||
|
@ -868,7 +962,7 @@ class Onboarding {
|
|||
// We use `innerHTML` for more friendly reading.
|
||||
// The security should be fine because this is not from an external input.
|
||||
div.innerHTML = `
|
||||
<div id="onboarding-overlay-dialog">
|
||||
<div role="dialog" tabindex="-1" aria-labelledby="onboarding-header">
|
||||
<header id="onboarding-header"></header>
|
||||
<nav>
|
||||
<ul id="onboarding-tour-list" role="tablist"></ul>
|
||||
|
@ -880,6 +974,9 @@ class Onboarding {
|
|||
</div>
|
||||
`;
|
||||
|
||||
this._dialog = div.querySelector(`[role="dialog"]`);
|
||||
this._dialog.id = ONBOARDING_DIALOG_ID;
|
||||
|
||||
div.querySelector("label[for='onboarding-tour-hidden-checkbox']").textContent =
|
||||
this._bundle.GetStringFromName("onboarding.hidden-checkbox-label-text");
|
||||
div.querySelector("#onboarding-header").textContent =
|
||||
|
@ -898,7 +995,7 @@ class Onboarding {
|
|||
button.setAttribute("aria-label", tooltip);
|
||||
button.id = "onboarding-overlay-button";
|
||||
button.setAttribute("aria-haspopup", true);
|
||||
button.setAttribute("aria-controls", "onboarding-overlay-dialog");
|
||||
button.setAttribute("aria-controls", `${ONBOARDING_DIALOG_ID}`);
|
||||
let img = this._window.document.createElement("img");
|
||||
img.id = "onboarding-overlay-button-icon";
|
||||
img.setAttribute("role", "presentation");
|
||||
|
@ -959,11 +1056,10 @@ class Onboarding {
|
|||
this.markTourCompletionState(tour.id);
|
||||
}
|
||||
|
||||
let dialog = this._window.document.getElementById("onboarding-overlay-dialog");
|
||||
let ul = this._window.document.getElementById("onboarding-tour-list");
|
||||
ul.appendChild(itemsFrag);
|
||||
let footer = this._window.document.getElementById("onboarding-footer");
|
||||
dialog.insertBefore(pagesFrag, footer);
|
||||
this._dialog.insertBefore(pagesFrag, footer);
|
||||
}
|
||||
|
||||
_loadCSS() {
|
||||
|
|
|
@ -63,3 +63,30 @@ add_task(async function test_onboarding_notification_bar() {
|
|||
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
|
||||
add_task(async function test_onboarding_overlay_dialog() {
|
||||
resetOnboardingDefaultState();
|
||||
|
||||
info("Wait for onboarding overlay loaded");
|
||||
let tab = await openTab(ABOUT_HOME_URL);
|
||||
let browser = tab.linkedBrowser;
|
||||
await promiseOnboardingOverlayLoaded(browser);
|
||||
|
||||
info("Test accessibility and semantics of the dialog overlay");
|
||||
await assertModalDialog(browser, { visible: false });
|
||||
|
||||
info("Click on overlay button and check modal dialog state");
|
||||
await BrowserTestUtils.synthesizeMouseAtCenter("#onboarding-overlay-button",
|
||||
{}, browser);
|
||||
await promiseOnboardingOverlayOpened(browser);
|
||||
await assertModalDialog(browser,
|
||||
{ visible: true, focusedId: "onboarding-overlay-dialog" });
|
||||
|
||||
info("Close the dialog and check modal dialog state");
|
||||
await BrowserTestUtils.synthesizeMouseAtCenter("#onboarding-overlay-close-btn",
|
||||
{}, browser);
|
||||
await promiseOnboardingOverlayClosed(browser);
|
||||
await assertModalDialog(browser, { visible: false });
|
||||
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
|
|
|
@ -4,19 +4,29 @@
|
|||
|
||||
"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}`);
|
||||
function assertOverlayState(browser, args) {
|
||||
return ContentTask.spawn(browser, args, ({ tourId, focusedId, visible }) => {
|
||||
let { document: doc, window} = content;
|
||||
if (tourId) {
|
||||
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"));
|
||||
}
|
||||
if (focusedId) {
|
||||
let focused = doc.getElementById(focusedId);
|
||||
is(focused, doc.activeElement, `Focus should be set on ${focusedId}`);
|
||||
}
|
||||
if (visible !== undefined) {
|
||||
let overlay = doc.getElementById("onboarding-overlay");
|
||||
is(window.getComputedStyle(overlay).getPropertyValue("display"),
|
||||
visible ? "block" : "none",
|
||||
`Onboarding overlay should be ${visible ? "visible" : "invisible"}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const TEST_DATA = [
|
||||
const TOUR_LIST_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] }},
|
||||
|
@ -32,6 +42,21 @@ const TEST_DATA = [
|
|||
{ key: " ", expected: { tourId: TOUR_IDs[2], focusedId: TOUR_IDs[2] }}
|
||||
];
|
||||
|
||||
const BUTTONS_TEST_DATA = [
|
||||
{ key: " ", expected: { focusedId: TOUR_IDs[0], visible: true }},
|
||||
{ key: "VK_ESCAPE", expected: { focusedId: "onboarding-overlay-button", visible: false }},
|
||||
{ key: "VK_RETURN", expected: { focusedId: TOUR_IDs[1], visible: true }},
|
||||
{ key: "VK_TAB", options: { shiftKey: true }, expected: { focusedId: TOUR_IDs[0], visible: true }},
|
||||
{ key: "VK_TAB", options: { shiftKey: true }, expected: { focusedId: "onboarding-overlay-close-btn", visible: true }},
|
||||
{ key: " ", expected: { focusedId: "onboarding-overlay-button", visible: false }},
|
||||
{ key: "VK_RETURN", expected: { focusedId: TOUR_IDs[1], visible: true }},
|
||||
{ key: "VK_TAB", options: { shiftKey: true }, expected: { focusedId: TOUR_IDs[0], visible: true }},
|
||||
{ key: "VK_TAB", options: { shiftKey: true }, expected: { focusedId: "onboarding-overlay-close-btn", visible: true }},
|
||||
{ key: "VK_TAB", expected: { focusedId: TOUR_IDs[0], visible: true }},
|
||||
{ key: "VK_TAB", options: { shiftKey: true }, expected: { focusedId: "onboarding-overlay-close-btn", visible: true }},
|
||||
{ key: "VK_RETURN", expected: { focusedId: "onboarding-overlay-button", visible: false }}
|
||||
];
|
||||
|
||||
add_task(async function test_tour_list_keyboard_navigation() {
|
||||
resetOnboardingDefaultState();
|
||||
|
||||
|
@ -48,14 +73,65 @@ add_task(async function test_tour_list_keyboard_navigation() {
|
|||
info("Set initial focus on the currently active tab");
|
||||
await ContentTask.spawn(tab.linkedBrowser, {}, () =>
|
||||
content.document.querySelector(".onboarding-active").focus());
|
||||
await assertTourList(tab.linkedBrowser,
|
||||
await assertOverlayState(tab.linkedBrowser,
|
||||
{ tourId: TOUR_IDs[0], focusedId: TOUR_IDs[0] });
|
||||
|
||||
for (let { key, options = {}, expected } of TEST_DATA) {
|
||||
for (let { key, options = {}, expected } of TOUR_LIST_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 assertOverlayState(tab.linkedBrowser, expected);
|
||||
}
|
||||
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
|
||||
add_task(async function test_buttons_keyboard_navigation() {
|
||||
resetOnboardingDefaultState();
|
||||
|
||||
info("Wait for onboarding overlay loaded");
|
||||
let tab = await openTab(ABOUT_HOME_URL);
|
||||
await promiseOnboardingOverlayLoaded(tab.linkedBrowser);
|
||||
|
||||
info("Set keyboard focus on the onboarding overlay button");
|
||||
await ContentTask.spawn(tab.linkedBrowser, {}, () =>
|
||||
content.document.getElementById("onboarding-overlay-button").focus());
|
||||
await assertOverlayState(tab.linkedBrowser,
|
||||
{ focusedId: "onboarding-overlay-button", visible: false });
|
||||
|
||||
for (let { key, options = {}, expected } of BUTTONS_TEST_DATA) {
|
||||
info(`Pressing ${key} to have ${expected.visible ? "visible" : "invisible"} overlay and have focus on ${expected.focusedId}`);
|
||||
await BrowserTestUtils.synthesizeKey(key, options, tab.linkedBrowser);
|
||||
await assertOverlayState(tab.linkedBrowser, expected);
|
||||
}
|
||||
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
|
||||
add_task(async function test_overlay_dialog_keyboard_navigation() {
|
||||
resetOnboardingDefaultState();
|
||||
|
||||
info("Wait for onboarding overlay loaded");
|
||||
let tab = await openTab(ABOUT_HOME_URL);
|
||||
let browser = tab.linkedBrowser;
|
||||
await promiseOnboardingOverlayLoaded(browser);
|
||||
|
||||
info("Test accessibility and semantics of the dialog overlay");
|
||||
await assertModalDialog(browser, { visible: false });
|
||||
|
||||
info("Set keyboard focus on the onboarding overlay button");
|
||||
await ContentTask.spawn(browser, {}, () =>
|
||||
content.document.getElementById("onboarding-overlay-button").focus());
|
||||
info("Open dialog with keyboard and check the dialog state");
|
||||
await BrowserTestUtils.synthesizeKey(" ", {}, browser);
|
||||
await promiseOnboardingOverlayOpened(browser);
|
||||
await assertModalDialog(browser,
|
||||
{ visible: true, keyboardFocus: true, focusedId: TOUR_IDs[0] });
|
||||
|
||||
info("Close the dialog and check modal dialog state");
|
||||
await BrowserTestUtils.synthesizeKey("VK_ESCAPE", {}, browser);
|
||||
await promiseOnboardingOverlayClosed(browser);
|
||||
await assertModalDialog(browser,
|
||||
{ visible: false, keyboardFocus: true, focusedId: "onboarding-overlay-button" });
|
||||
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
|
|
|
@ -87,21 +87,22 @@ function promiseOnboardingOverlayLoaded(browser) {
|
|||
}
|
||||
|
||||
function promiseOnboardingOverlayOpened(browser) {
|
||||
let condition = () => {
|
||||
return ContentTask.spawn(browser, {}, function() {
|
||||
return new Promise(resolve => {
|
||||
let overlay = content.document.querySelector("#onboarding-overlay");
|
||||
if (overlay.classList.contains("onboarding-opened")) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
return BrowserTestUtils.waitForCondition(
|
||||
condition,
|
||||
"Should open onboarding overlay",
|
||||
return BrowserTestUtils.waitForCondition(() =>
|
||||
ContentTask.spawn(browser, {}, () =>
|
||||
content.document.querySelector("#onboarding-overlay").classList.contains(
|
||||
"onboarding-opened")),
|
||||
"Should close onboarding overlay",
|
||||
100,
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
function promiseOnboardingOverlayClosed(browser) {
|
||||
return BrowserTestUtils.waitForCondition(() =>
|
||||
ContentTask.spawn(browser, {}, () =>
|
||||
!content.document.querySelector("#onboarding-overlay").classList.contains(
|
||||
"onboarding-opened")),
|
||||
"Should close onboarding overlay",
|
||||
100,
|
||||
30
|
||||
);
|
||||
|
@ -209,9 +210,17 @@ function assertOverlaySemantics(browser) {
|
|||
return ContentTask.spawn(browser, {}, function() {
|
||||
let doc = content.document;
|
||||
|
||||
info("Checking dialog");
|
||||
let dialog = doc.getElementById("onboarding-overlay-dialog");
|
||||
is(dialog.getAttribute("role"), "dialog",
|
||||
"Dialog should have a dialog role attribute set");
|
||||
is(dialog.tabIndex, "-1", "Dialog should be focusable but not in tab order");
|
||||
is(dialog.getAttribute("aria-labelledby"), "onboarding-header",
|
||||
"Dialog should be labaled by its header");
|
||||
|
||||
info("Checking the tablist container");
|
||||
is(doc.getElementById("onboarding-tour-list").getAttribute("role"), "tablist",
|
||||
"Tour list should have a tablist role argument set");
|
||||
"Tour list should have a tablist role attribute set");
|
||||
|
||||
info("Checking each tour item that represents the tab");
|
||||
let items = [...doc.querySelectorAll(".onboarding-tour-item")];
|
||||
|
@ -222,15 +231,43 @@ function assertOverlaySemantics(browser) {
|
|||
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");
|
||||
is(item.getAttribute("role"), "tab", "Item should have a tab role attribute 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");
|
||||
"Tour panel should have a tabpanel role attribute set");
|
||||
is(panel.getAttribute("aria-labelledby"), item.id,
|
||||
"Tour panel should have aria-labelledby attribute point to its tab");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertModalDialog(browser, args) {
|
||||
return ContentTask.spawn(browser, args, ({ keyboardFocus, visible, focusedId }) => {
|
||||
let doc = content.document;
|
||||
let overlayButton = doc.getElementById("onboarding-overlay-button");
|
||||
if (visible) {
|
||||
[...doc.body.children].forEach(child =>
|
||||
child.id !== "onboarding-overlay" &&
|
||||
is(child.getAttribute("aria-hidden"), "true",
|
||||
"Content should not be visible to screen reader"));
|
||||
is(focusedId ? doc.getElementById(focusedId) : doc.body,
|
||||
doc.activeElement, `Focus should be on ${focusedId || "body"}`);
|
||||
is(keyboardFocus ? "true" : undefined,
|
||||
overlayButton.dataset.keyboardFocus,
|
||||
"Overlay button focus state is saved correctly");
|
||||
} else {
|
||||
[...doc.body.children].forEach(
|
||||
child => ok(!child.hasAttribute("aria-hidden"),
|
||||
"Content should be visible to screen reader"));
|
||||
if (keyboardFocus) {
|
||||
is(overlayButton, doc.activeElement,
|
||||
"Focus should be set on overlay button");
|
||||
}
|
||||
ok(!overlayButton.dataset.keyboardFocus,
|
||||
"Overlay button focus state should be cleared");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче