Bug 1573197 - Make the bar chart in the protection report accessible, r=mtigley,fluent-reviewers,flod
authorMarco Zehe <mzehe@mozilla.com>
Tue, 13 Aug 2019 16:07:41 +0000
changeset 487687 9700ef1693eddebd6b9c03aedc66a130f9409c20
parent 487685 5d84ea4c115a32a6a7b4d8e9a024cbda22681839
child 487688 5198dc0ba7b2113c08b0bab31e392b8afe93ce95
push id36430
push userdvarga@mozilla.com
push dateWed, 14 Aug 2019 04:09:17 +0000
treeherdermozilla-central@d3deef805f92 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmtigley, fluent-reviewers, flod
bugs1573197
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1573197 - Make the bar chart in the protection report accessible, r=mtigley,fluent-reviewers,flod To make the graph accessible, we turn the graph itself into a WAI-ARIA table. Each day then becomes a row within that table. Within each row, we first have a total number, and then for each bar, we add another table cell. We record the widest row and add that to the table for assistive technologies to know how many columns there are. In addition, we take the day legend and make that into the first column via aria-owns. The day becomes the header for the row. This changes the accessible tre structure which now differs significantly from the DOM structure. Differential Revision: https://phabricator.services.mozilla.com/D41593
browser/components/protections/content/protections.ftl
browser/components/protections/content/protections.html
browser/components/protections/content/protections.js
browser/components/protections/test/browser/browser_protections_report_ui.js
--- a/browser/components/protections/content/protections.ftl
+++ b/browser/components/protections/content/protections.ftl
@@ -132,18 +132,50 @@ password-warning =
   }
 
 # This is the title attribute describing the graph report's link to about:settings#privacy
 go-to-privacy-settings = Go to Privacy Settings
 
 # This is the title attribute describing the Lockwise card's link to about:logins
 go-to-saved-logins = Go to Saved Logins
 
+## The title attribute is used to display the type of protection.
+## The aria-label is spoken by screen readers to make the visual graph accessible to blind users.
+##
+## Variables:
+##   $count (Number) - Number of specific trackers
+##   $percentage (Number) - Percentage this type of tracker contributes to the whole graph
+
 bar-tooltip-social =
   .title = Social Media Trackers
+  .aria-label =
+    { $count ->
+       [one] { $count } social media tracker ({ $percentage }%)
+      *[other] { $count } social media trackers ({ $percentage }%)
+    }
 bar-tooltip-cookie =
   .title = Cross-Site Tracking Cookies
+  .aria-label =
+    { $count ->
+       [one] { $count } cross-site tracking cookie ({ $percentage }%)
+      *[other] { $count } cross-site tracking cookies ({ $percentage }%)
+    }
 bar-tooltip-tracker =
   .title = Tracking Content
+  .aria-label =
+    { $count ->
+       [one] { $count } tracking content ({ $percentage }%)
+      *[other] { $count } tracking content ({ $percentage }%)
+    }
 bar-tooltip-fingerprinter =
   .title = Fingerprinters
+  .aria-label =
+    { $count ->
+       [one] { $count } fingerprinter ({ $percentage }%)
+      *[other] { $count } fingerprinters ({ $percentage }%)
+    }
 bar-tooltip-cryptominer =
   .title = Cryptominers
+  .aria-label =
+    { $count ->
+       [one] { $count } cryptominer ({ $percentage }%)
+      *[other] { $count } cryptominers ({ $percentage }%)
+    }
--- a/browser/components/protections/content/protections.html
+++ b/browser/components/protections/content/protections.html
@@ -32,17 +32,17 @@
             <p class="content" data-l10n-id="etp-card-content"></p>
             <p id="protection-details" role="link" tabindex="0" data-l10n-title="go-to-privacy-settings"></p>
           </div>
         </div>
         <div class="card-body">
           <div class="body-wrapper">
             <p id="graph-week-summary"></p>
             <div id="graph-wrapper">
-              <div id="graph"></div>
+              <div id="graph" role="table"></div>
               <div id="legend">
                 <label id="graphLegendDescription" data-l10n-id="graph-legend-description"></label>
                 <input id="tab-social" data-type="social" type="radio" name="tabs" aria-describedby="socialTitle" checked>
                 <label for="tab-social" data-type="social"></label>
 
                 <input id="tab-cookie" data-type="cookie" type="radio" name="tabs" aria-describedby="cookieTitle">
                 <label for="tab-cookie" data-type="cookie"></label>
 
--- a/browser/components/protections/content/protections.js
+++ b/browser/components/protections/content/protections.js
@@ -94,69 +94,106 @@ document.addEventListener("DOMContentLoa
     let weekTypeCounts = {
       social: 0,
       cookie: 0,
       tracker: 0,
       fingerprinter: 0,
       cryptominer: 0,
     };
 
+    // For accessibility clients, we turn the graph into a fake table with annotated text.
+    // We use WAI-ARIA roles, properties, and states to mark up the table, rows and cells.
+    // Each day becomes one row in the table.
+    // Each row contains the day, total, and then one cell for each bar that we display.
+    // At most, a row can contain seven cells.
+    // But we need to caclulate the actual number of the most cells in a row to give accurate information.
+    let maxColumnCount = 0;
     let date = new Date();
+    // The graph is already a role "table" from the HTML file.
     let graph = document.getElementById("graph");
     for (let i = 0; i <= 6; i++) {
       let dateString = date.toISOString().split("T")[0];
+      let ariaOwnsString = ""; // Get the row's colummns in order
+      let currentColumnCount = 0;
 
       let bar = document.createElement("div");
       bar.className = "graph-bar";
+      bar.setAttribute("role", "row");
       let innerBar = document.createElement("div");
       innerBar.className = "graph-wrapper-bar";
       if (data[dateString]) {
         let content = data[dateString];
         let count = document.createElement("div");
         count.className = "bar-count";
+        count.id = "count" + i;
+        count.setAttribute("role", "cell");
         count.textContent = content.total;
         bar.appendChild(count);
+        ariaOwnsString = count.id;
+        currentColumnCount += 1;
         let barHeight = (content.total / largest) * 100;
         weekCount += content.total;
         bar.style.height = `${barHeight}%`;
         for (let type of dataTypes) {
           if (content[type]) {
             let dataHeight = (content[type] / content.total) * 100;
+            // Since we are dealing with non-visual content, screen readers need a parent container to get the text
+            let cellSpan = document.createElement("span");
+            cellSpan.id = type + i;
+            cellSpan.setAttribute("role", "cell");
             let div = document.createElement("div");
             div.className = `${type}-bar inner-bar`;
             div.setAttribute("data-type", type);
             div.style.height = `${dataHeight}%`;
+            div.setAttribute(
+              "data-l10n-args",
+              JSON.stringify({ count: content[type], percentage: dataHeight })
+            );
             div.setAttribute("data-l10n-id", `bar-tooltip-${type}`);
             weekTypeCounts[type] += content[type];
-            innerBar.appendChild(div);
+            cellSpan.appendChild(div);
+            innerBar.appendChild(cellSpan);
+            ariaOwnsString = ariaOwnsString + " " + cellSpan.id;
+            currentColumnCount += 1;
           }
         }
+        if (currentColumnCount > maxColumnCount) {
+          // The current row has more than any previous rows
+          maxColumnCount = currentColumnCount;
+        }
       } else {
         // There were no content blocking events on this day.
         bar.classList.add("empty");
       }
       bar.appendChild(innerBar);
       graph.prepend(bar);
       let weekSummary = document.getElementById("graph-week-summary");
       weekSummary.setAttribute(
         "data-l10n-args",
         JSON.stringify({ count: weekCount })
       );
       weekSummary.setAttribute("data-l10n-id", "graph-week-summary");
 
       let label = document.createElement("span");
       label.className = "column-label";
+      // While the graphs fill up from the right, the days fill up from the left, so match the IDs
+      label.id = "day" + (6 - i);
+      label.setAttribute("role", "rowheader");
       if (i == 6) {
         label.setAttribute("data-l10n-id", "graph-today");
       } else {
         label.textContent = data.weekdays[(i + 1 + new Date().getDay()) % 7];
       }
       graph.append(label);
+      // Make the day the first column in a row, making it the row header.
+      bar.setAttribute("aria-owns", "day" + i + " " + ariaOwnsString);
       date.setDate(date.getDate() - 1);
     }
+    maxColumnCount += 1; // Add the day column in the fake table
+    graph.setAttribute("aria-colCount", maxColumnCount);
     // Set the total number of each type of tracker on the tabs as well as their
     // "Learn More" links
     for (let type of dataTypes) {
       document.querySelector(`label[data-type=${type}]`).textContent =
         weekTypeCounts[type];
       const learnMoreLink = document.getElementById(`${type}-link`);
       learnMoreLink.href = RPMGetFormatURLPref(
         `browser.contentblocking.report.${type}.url`
--- a/browser/components/protections/test/browser/browser_protections_report_ui.js
+++ b/browser/components/protections/test/browser/browser_protections_report_ui.js
@@ -188,110 +188,213 @@ add_task(async function test_graph_displ
     let allBars = null;
     await ContentTaskUtils.waitForCondition(() => {
       allBars = content.document.querySelectorAll(".graph-bar");
       return allBars.length;
     }, "The graph has been built");
 
     is(allBars.length, 7, "7 bars have been found on the graph");
 
+    // For accessibility, test if the graph is a table
+    // and has a correct column count (number of data types + total + day)
+    is(
+      content.document.getElementById("graph").getAttribute("role"),
+      "table",
+      "Graph is an accessible table"
+    );
+    is(
+      content.document.getElementById("graph").getAttribute("aria-colcount"),
+      DATA_TYPES.length + 2,
+      "Table has the right number of columns"
+    );
+
     // today has each type
     // yesterday will have no tracking cookies
     // 2 days ago will have no fingerprinters
     // 3 days ago will have no cryptominers
     // 4 days ago will have no trackers
     // 5 days ago will have no social (when we add social)
     // 6 days ago will be empty
     is(
       allBars[6].querySelectorAll(".inner-bar").length,
       DATA_TYPES.length,
       "today has all of the data types shown"
     );
+    is(allBars[6].getAttribute("role"), "row", "Today has the correct role");
+    is(
+      allBars[6].getAttribute("aria-owns"),
+      "day0 count0 cryptominer0 fingerprinter0 tracker0 cookie0 social0",
+      "Row has the columns in the right order"
+    );
     is(
       allBars[6].querySelector(".tracker-bar").style.height,
       "10%",
       "trackers take 10%"
     );
     is(
+      allBars[6].querySelector(".tracker-bar").parentNode.getAttribute("role"),
+      "cell",
+      "Trackers have the correct role"
+    );
+    is(
+      allBars[6].querySelector(".tracker-bar").getAttribute("aria-label"),
+      "1 tracking content (10%)",
+      "Trackers have the correct accessible text"
+    );
+    is(
       allBars[6].querySelector(".cryptominer-bar").style.height,
       "20%",
       "cryptominers take 20%"
     );
     is(
+      allBars[6]
+        .querySelector(".cryptominer-bar")
+        .parentNode.getAttribute("role"),
+      "cell",
+      "Cryptominers have the correct role"
+    );
+    is(
+      allBars[6].querySelector(".cryptominer-bar").getAttribute("aria-label"),
+      "2 cryptominers (20%)",
+      "Cryptominers have the correct accessible label"
+    );
+    is(
       allBars[6].querySelector(".fingerprinter-bar").style.height,
       "20%",
       "fingerprinters take 20%"
     );
     is(
+      allBars[6]
+        .querySelector(".fingerprinter-bar")
+        .parentNode.getAttribute("role"),
+      "cell",
+      "Fingerprinters have the correct role"
+    );
+    is(
+      allBars[6].querySelector(".fingerprinter-bar").getAttribute("aria-label"),
+      "2 fingerprinters (20%)",
+      "Fingerprinters have the correct accessible label"
+    );
+    is(
       allBars[6].querySelector(".cookie-bar").style.height,
       "40%",
       "cross site tracking cookies take 40%"
     );
     is(
+      allBars[6].querySelector(".cookie-bar").parentNode.getAttribute("role"),
+      "cell",
+      "cross site tracking cookies have the correct role"
+    );
+    is(
+      allBars[6].querySelector(".cookie-bar").getAttribute("aria-label"),
+      "4 cross-site tracking cookies (40%)",
+      "cross site tracking cookies have the correct accessible label"
+    );
+    is(
       allBars[6].querySelector(".social-bar").style.height,
       "10%",
       "social trackers take 10%"
     );
+    is(
+      allBars[6].querySelector(".social-bar").parentNode.getAttribute("role"),
+      "cell",
+      "social trackers have the correct role"
+    );
+    is(
+      allBars[6].querySelector(".social-bar").getAttribute("aria-label"),
+      "1 social media tracker (10%)",
+      "social trackers have the correct accessible text"
+    );
 
     is(
       allBars[5].querySelectorAll(".inner-bar").length,
       DATA_TYPES.length - 1,
       "1 day ago is missing one type"
     );
     ok(
       !allBars[5].querySelector(".cookie-bar"),
       "there is no cross site tracking cookie section 1 day ago."
     );
+    is(
+      allBars[5].getAttribute("aria-owns"),
+      "day1 count1 cryptominer1 fingerprinter1 tracker1 social1",
+      "Row has the columns in the right order"
+    );
 
     is(
       allBars[4].querySelectorAll(".inner-bar").length,
       DATA_TYPES.length - 1,
       "2 days ago is missing one type"
     );
     ok(
       !allBars[4].querySelector(".fingerprinter-bar"),
       "there is no fingerprinter section 1 day ago."
     );
+    is(
+      allBars[4].getAttribute("aria-owns"),
+      "day2 count2 cryptominer2 tracker2 cookie2 social2",
+      "Row has the columns in the right order"
+    );
 
     is(
       allBars[3].querySelectorAll(".inner-bar").length,
       DATA_TYPES.length - 1,
       "3 days ago is missing one type"
     );
     ok(
       !allBars[3].querySelector(".cryptominer-bar"),
       "there is no cryptominer section 1 day ago."
     );
+    is(
+      allBars[3].getAttribute("aria-owns"),
+      "day3 count3 fingerprinter3 tracker3 cookie3 social3",
+      "Row has the columns in the right order"
+    );
 
     is(
       allBars[2].querySelectorAll(".inner-bar").length,
       DATA_TYPES.length - 1,
       "4 days ago is missing one type"
     );
     ok(
       !allBars[2].querySelector(".tracker-bar"),
       "there is no tracker section 1 day ago."
     );
+    is(
+      allBars[2].getAttribute("aria-owns"),
+      "day4 count4 cryptominer4 fingerprinter4 cookie4 social4",
+      "Row has the columns in the right order"
+    );
 
     is(
       allBars[1].querySelectorAll(".inner-bar").length,
       DATA_TYPES.length - 1,
       "5 days ago is missing one type"
     );
     ok(
       !allBars[1].querySelector(".social-bar"),
       "there is no social section 1 day ago."
     );
+    is(
+      allBars[1].getAttribute("aria-owns"),
+      "day5 count5 cryptominer5 fingerprinter5 tracker5 cookie5",
+      "Row has the columns in the right order"
+    );
 
     is(
       allBars[0].querySelectorAll(".inner-bar").length,
       0,
       "6 days ago has no content"
     );
     ok(allBars[0].classList.contains("empty"), "6 days ago is an empty bar");
+    is(
+      allBars[0].getAttribute("aria-owns"),
+      "day6 ",
+      "Row has the columns in the right order"
+    );
 
     // Check that each tab has the correct aria-describedby value. This helps screen readers
     // know what type of tracker the reported tab number is referencing.
     const socialTab = content.document.getElementById("tab-social");
     is(
       socialTab.getAttribute("aria-describedby"),
       "socialTitle",
       "aria-describedby attribute is socialTitle"