Bug 1377276 - Add modal dialog semantics and better accessibility for onboarding overlay dialog. r=mossop, r=gasolin, r=rexboy, a=lizzard
authorYura Zenevich <yura.zenevich@gmail.com>
Tue, 01 Aug 2017 12:55:21 -0400
changeset 423794 b7bedfe12a26436750f93f72d4727eeadf8ba28a
parent 423793 41a7a297a2fbff5bbe34cf6c51eff218a93d6ad8
child 423795 c6f8dafb660fe8a0ad9805a769644d358d3bb2b1
push id1517
push userjlorenzo@mozilla.com
push dateThu, 14 Sep 2017 16:50:54 +0000
treeherdermozilla-release@3b41fd564418 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmossop, gasolin, rexboy, lizzard
bugs1377276
milestone56.0
Bug 1377276 - Add modal dialog semantics and better accessibility for onboarding overlay dialog. r=mossop, r=gasolin, r=rexboy, a=lizzard MozReview-Commit-ID: 9xyhn7jLJqD
browser/extensions/onboarding/content/onboarding.js
browser/extensions/onboarding/test/browser/browser_onboarding_accessibility.js
browser/extensions/onboarding/test/browser/browser_onboarding_keyboard.js
browser/extensions/onboarding/test/browser/head.js
--- a/browser/extensions/onboarding/content/onboarding.js
+++ b/browser/extensions/onboarding/content/onboarding.js
@@ -15,16 +15,17 @@ const ABOUT_HOME_URL = "about:home";
 const ABOUT_NEWTAB_URL = "about:newtab";
 const BUNDLE_URI = "chrome://onboarding/locale/onboarding.properties";
 const UITOUR_JS_URI = "resource://onboarding/lib/UITour-lib.js";
 const TOUR_AGENT_JS_URI = "resource://onboarding/onboarding-tour-agent.js";
 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
  * "tourId": { // The short tour id which could be saved in pref
  *   // The unique tour id
  *   id: "onboarding-tour-addons",
  *   // The string id of tour name which would be displayed on the navigation bar
  *   tourNameId: "onboarding.tour-addon",
@@ -392,16 +393,17 @@ class Onboarding {
     }
     this.uiInitialized = true;
     this._tourItems = [];
     this._tourPages = [];
 
     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();
     this._overlay.addEventListener("click", this);
     this._overlay.addEventListener("keypress", this);
     body.appendChild(this._overlay);
 
     this._loadJS(TOUR_AGENT_JS_URI);
@@ -460,34 +462,42 @@ class Onboarding {
     if (this._prefsObserved) {
       for (let [name, callback] of this._prefsObserved) {
         Services.prefs.removeObserver(name, callback);
       }
       this._prefsObserved = null;
     }
   }
 
+  /**
+   * 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,
     // actual semantic tab is their first child.
     if (classList.contains("onboarding-tour-item-container")) {
       ({ id, classList } = target.firstChild);
     }
 
     switch (id) {
       case "onboarding-overlay-button":
       case "onboarding-overlay-close-btn":
       // If the clicking target is directly on the outer-most overlay,
       // that means clicking outside the tour content area.
       // 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();
         this._removeTourFromNotificationQueue(this._notificationBar.dataset.targetTourId);
         break;
       case "onboarding-notification-action-btn":
         let tourId = this._notificationBar.dataset.targetTourId;
         this.toggleOverlay();
@@ -507,18 +517,56 @@ class Onboarding {
       // 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 ]);
     }
   }
 
+  /**
+   * 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")) {
       target = target.firstChild;
     }
     let targetIndex;
     switch (key) {
       case " ":
@@ -545,16 +593,26 @@ class Onboarding {
         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;
+      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;
     }
     event.stopPropagation();
   }
 
   handleEvent(evt) {
     switch (evt.type) {
@@ -594,23 +652,59 @@ class Onboarding {
   toggleOverlay() {
     if (this._tourItems.length == 0) {
       // Lazy loading until first toggle.
       this._loadTours(this._tours);
     }
 
     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) {
       this.hide();
     }
   }
 
+  /**
+   * 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) {
       if (page.id === targetPageId) {
         page.style.display = "";
         page.dispatchEvent(new this._window.CustomEvent("beforeshow"));
       } else {
         page.style.display = "none";
@@ -888,28 +982,31 @@ class Onboarding {
   }
 
   _renderOverlay() {
     let div = this._window.document.createElement("div");
     div.id = "onboarding-overlay";
     // 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>
         </nav>
         <footer id="onboarding-footer">
           <input type="checkbox" id="onboarding-tour-hidden-checkbox" /><label for="onboarding-tour-hidden-checkbox"></label>
         </footer>
         <button id="onboarding-overlay-close-btn" class="onboarding-close-btn"></button>
       </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 =
       this._bundle.GetStringFromName("onboarding.overlay-title2");
     let closeBtn = div.querySelector("#onboarding-overlay-close-btn");
     closeBtn.setAttribute("title",
       this._bundle.GetStringFromName("onboarding.overlay-close-button-tooltip"));
     return div;
@@ -918,17 +1015,17 @@ class Onboarding {
   _renderOverlayButton() {
     let button = this._window.document.createElement("button");
     let tooltipStringId = this._tourType === "new" ?
       "onboarding.overlay-icon-tooltip" : "onboarding.overlay-icon-tooltip-updated";
     let tooltip = this._bundle.formatStringFromName(tooltipStringId, [BRAND_SHORT_NAME], 1);
     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");
     img.src = "resource://onboarding/img/overlay-icon.svg";
     button.appendChild(img);
     return button;
   }
 
@@ -979,21 +1076,20 @@ class Onboarding {
       pagesFrag.appendChild(div);
       // Cache elements in arrays for later use to avoid cost of querying elements
       this._tourItems.push(tab);
       this._tourPages.push(div);
 
       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() {
     // Returning a Promise so we can inform caller of loading complete
     // by resolving it.
     return new Promise(resolve => {
       let doc = this._window.document;
       let link = doc.createElement("link");
--- a/browser/extensions/onboarding/test/browser/browser_onboarding_accessibility.js
+++ b/browser/extensions/onboarding/test/browser/browser_onboarding_accessibility.js
@@ -58,8 +58,35 @@ add_task(async function test_onboarding_
       "onboarding-notification-body"
     ].forEach(id =>
       is(doc.getElementById(id).getAttribute("role"), "presentation",
         "Element is only used for presentation"));
   });
 
   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);
+});
--- a/browser/extensions/onboarding/test/browser/browser_onboarding_keyboard.js
+++ b/browser/extensions/onboarding/test/browser/browser_onboarding_keyboard.js
@@ -1,61 +1,135 @@
 /* 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}`);
+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] }},
   { key: "VK_DOWN", expected: { tourId: TOUR_IDs[4], focusedId: TOUR_IDs[4] }},
   { key: "VK_UP", expected: { tourId: TOUR_IDs[3], focusedId: TOUR_IDs[3] }},
   { key: "VK_UP", expected: { tourId: TOUR_IDs[2], focusedId: TOUR_IDs[2] }},
   { key: "VK_TAB", expected: { tourId: TOUR_IDs[2], focusedId: TOUR_IDs[3] }},
   { key: "VK_TAB", expected: { tourId: TOUR_IDs[2], focusedId: TOUR_IDs[4] }},
   { key: "VK_RETURN", expected: { tourId: TOUR_IDs[4], focusedId: TOUR_IDs[4] }},
   { key: "VK_TAB", options: { shiftKey: true }, expected: { tourId: TOUR_IDs[4], focusedId: TOUR_IDs[3] }},
   { key: "VK_TAB", options: { shiftKey: true }, expected: { tourId: TOUR_IDs[4], focusedId: TOUR_IDs[2] }},
   // VK_SPACE does not work well with EventUtils#synthesizeKey use " " instead
   { 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[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[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();
 
   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,
+  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);
+});
--- a/browser/extensions/onboarding/test/browser/head.js
+++ b/browser/extensions/onboarding/test/browser/head.js
@@ -72,31 +72,32 @@ function promiseOnboardingOverlayLoaded(
       });
       observer.observe(doc.body, { childList: true });
     });
   }
   return ContentTask.spawn(browser, {}, isLoaded);
 }
 
 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
   );
 }
 
 function promisePrefUpdated(name, expectedValue) {
   return new Promise(resolve => {
     let onUpdate = actualValue => {
@@ -194,33 +195,69 @@ 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 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")];
     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");
+      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");
+    }
+  });
+}