Bug 1377298 - Improve semantics and keyboard accessibility of tour tabs UI in onboarding overlay. r=mossop, r=gasolin, a=lizzard
authorYura Zenevich <yura.zenevich@gmail.com>
Mon, 31 Jul 2017 09:40:32 -0400
changeset 423793 41a7a297a2fbff5bbe34cf6c51eff218a93d6ad8
parent 423792 3d052ef01e8efead6ac48ef06e4c2a2632aa2c3c
child 423794 b7bedfe12a26436750f93f72d4727eeadf8ba28a
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, lizzard
bugs1377298
milestone56.0
Bug 1377298 - Improve semantics and keyboard accessibility of tour tabs UI in onboarding overlay. r=mossop, r=gasolin, a=lizzard MozReview-Commit-ID: Iay3mL6RJKF
browser/extensions/onboarding/content/onboarding.css
browser/extensions/onboarding/content/onboarding.js
browser/extensions/onboarding/test/browser/browser.ini
browser/extensions/onboarding/test/browser/browser_onboarding_keyboard.js
browser/extensions/onboarding/test/browser/browser_onboarding_tours.js
browser/extensions/onboarding/test/browser/head.js
--- a/browser/extensions/onboarding/content/onboarding.css
+++ b/browser/extensions/onboarding/content/onboarding.css
@@ -152,50 +152,56 @@
   margin-inline-start: 27px;
   margin-inline-end: 10px;
 }
 
 /* Onboarding tour list */
 #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;
   background-size: 20px;
   font-size: 16px;
   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;
   top: -10px;
   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;
 }
 
 /* Default browser tour */
 #onboarding-tour-is-default-browser-msg {
   font-size: 16px;
@@ -323,17 +329,18 @@
 .onboarding-tour-action-button::-moz-focus-inner,
 #onboarding-overlay-button::-moz-focus-inner,
 #onboarding-notification-action-btn::-moz-focus-inner {
   border: 0;
 }
 
 /* Keyboard focus specific outline */
 .onboarding-tour-action-button:-moz-focusring,
-#onboarding-notification-action-btn:-moz-focusring {
+#onboarding-notification-action-btn:-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;
 }
 
 .onboarding-tour-action-button:hover:not([disabled]) ,
 #onboarding-notification-action-btn:hover {
   background: #0060df;
--- a/browser/extensions/onboarding/content/onboarding.js
+++ b/browser/extensions/onboarding/content/onboarding.js
@@ -396,16 +396,17 @@ class Onboarding {
 
     let { body } = this._window.document;
     this._overlayIcon = this._renderOverlayButton();
     this._overlayIcon.addEventListener("click", 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);
 
     this._initPrefObserver();
     // Doing tour notification takes some effort. Let's do it on idle.
     this._window.requestIdleCallback(() => this._initNotification());
   }
@@ -459,26 +460,25 @@ class Onboarding {
     if (this._prefsObserved) {
       for (let [name, callback] of this._prefsObserved) {
         Services.prefs.removeObserver(name, callback);
       }
       this._prefsObserved = null;
     }
   }
 
-  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,
       // 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];
@@ -493,28 +493,92 @@ class Onboarding {
         this.toggleOverlay();
         this.gotoPage(tourId);
         this._removeTourFromNotificationQueue(tourId);
         break;
       // These tours are tagged completed instantly upon showing.
       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;
     }
     this.uiInitialized = false;
 
     this._clearPrefObserver();
     this._overlayIcon.remove();
@@ -547,21 +611,23 @@ class Onboarding {
     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";
       }
     }
-    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);
       }
     }
   }
 
   isTourCompleted(tourId) {
     return Services.prefs.getBoolPref(`browser.onboarding.tour.${tourId}.completed`, false);
   }
 
@@ -577,19 +643,42 @@ class Onboarding {
     });
     if (params.length > 0) {
       sendMessageToChrome("set-prefs", params);
     }
   }
 
   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", "Complete");
+        targetItem.appendChild(completedText);
+        targetItem.setAttribute("aria-describedby", completedTextId);
+      }
+    } else {
+      targetItem.classList.remove("onboarding-complete");
+      targetItem.removeAttribute("aria-describedby");
+      if (completedText) {
+        completedText.remove();
+      }
     }
   }
 
   _muteNotificationOnFirstSession() {
     if (Services.prefs.prefHasUserValue("browser.onboarding.notification.tour-ids-queue")) {
       // There is a queue. We had prompted before, this must not be the 1st session.
       return false;
     }
@@ -802,17 +891,17 @@ class Onboarding {
     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">
         <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>
         </footer>
         <button id="onboarding-overlay-close-btn" class="onboarding-close-btn"></button>
       </div>
     `;
 
@@ -844,40 +933,57 @@ class Onboarding {
   }
 
   _loadTours(tours) {
     let itemsFrag = this._window.document.createDocumentFragment();
     let pagesFrag = this._window.document.createDocumentFragment();
     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);
 
       // Do a traverse for elements in the page that need to be localized.
       let l10nElements = div.querySelectorAll("[data-l10n-id]");
       for (let i = 0; i < l10nElements.length; i++) {
         let element = l10nElements[i];
         // We always put brand short name as the first argument for it's the
         // only and frequently used arguments in our l10n case. Rewrite it if
         // other arguments appears.
         element.textContent = this._bundle.formatStringFromName(
                                 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);
     }
 
     let dialog = this._window.document.getElementById("onboarding-overlay-dialog");
     let ul = this._window.document.getElementById("onboarding-tour-list");
     ul.appendChild(itemsFrag);
--- a/browser/extensions/onboarding/test/browser/browser.ini
+++ b/browser/extensions/onboarding/test/browser/browser.ini
@@ -1,11 +1,13 @@
 [DEFAULT]
 support-files =
   head.js
 
 [browser_onboarding_accessibility.js]
+[browser_onboarding_keyboard.js]
+skip-if = debug || os == "mac" # Full keyboard navigation on OSX only works if Full Keyboard Access setting is set to All Control in System Keyboard Preferences
 [browser_onboarding_notification.js]
 [browser_onboarding_notification_2.js]
 [browser_onboarding_notification_3.js]
 [browser_onboarding_notification_4.js]
 [browser_onboarding_tours.js]
 [browser_onboarding_tourset.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/onboarding/test/browser/browser_onboarding_keyboard.js
@@ -0,0 +1,61 @@
+/* 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_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] }}
+];
+
+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);
+});
--- a/browser/extensions/onboarding/test/browser/browser_onboarding_tours.js
+++ b/browser/extensions/onboarding/test/browser/browser_onboarding_tours.js
@@ -13,23 +13,32 @@ function assertOnboardingDestroyed(brows
     ];
     for (let selector of expectedRemovals) {
       let removal = content.document.querySelector(selector);
       ok(!removal, `Should remove ${selector} onboarding element`);
     }
   });
 }
 
-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");
     }
   });
 }
 
 add_task(async function test_hide_onboarding_tours() {
   resetOnboardingDefaultState();
 
   let tourIds = TOUR_IDs;
@@ -74,18 +83,19 @@ add_task(async function test_click_actio
 
   let completedTourId = tourIds[0];
   let expectedPrefUpdate = promisePrefUpdated(`browser.onboarding.tour.${completedTourId}.completed`, true);
   await BrowserTestUtils.synthesizeMouseAtCenter(`#${completedTourId}-page .onboarding-tour-action-button`, {}, gBrowser.selectedBrowser);
   await expectedPrefUpdate;
 
   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);
   }
 });
 
 add_task(async function test_set_right_tour_completed_style_on_overlay() {
   resetOnboardingDefaultState();
 
@@ -101,14 +111,15 @@ add_task(async function test_set_right_t
     await promiseOnboardingOverlayLoaded(tab.linkedBrowser);
     await BrowserTestUtils.synthesizeMouseAtCenter("#onboarding-overlay-button", {}, tab.linkedBrowser);
     await promiseOnboardingOverlayOpened(tab.linkedBrowser);
     tabs.push(tab);
   }
 
   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);
   }
 });
--- a/browser/extensions/onboarding/test/browser/head.js
+++ b/browser/extensions/onboarding/test/browser/head.js
@@ -189,8 +189,38 @@ function waitUntilWindowIdle(browser) {
   return ContentTask.spawn(browser, {}, function() {
     return new Promise(resolve => content.requestIdleCallback(resolve));
   });
 }
 
 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");
+    });
+  });
+}