feat(core/a11y): add axe-core integration (#2779)

This commit is contained in:
Sid Vishnoi 2020-03-07 13:07:01 +00:00 committed by GitHub
parent f873b724b6
commit 3e52340aee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 196 additions and 3 deletions

View File

@ -221,6 +221,12 @@
display: inline;
}
.respec-warning-list > li li,
.respec-error-list > li li {
margin: 0;
list-style: disc;
}
#respec-overlay {
display: block;
position: fixed;

View File

@ -79,6 +79,7 @@ const modules = [
import("../src/core/custom-elements/index.js"),
/* Linter must be the last thing to run */
import("../src/core/linter.js"),
import("../src/core/a11y.js"),
];
async function domReady() {

View File

@ -62,8 +62,9 @@ const modules = [
import("../src/core/algorithms.js"),
import("../src/core/anchor-expander.js"),
import("../src/core/custom-elements/index.js"),
/* Linter must be the last thing to run */
/* Linters must be the last thing to run */
import("../src/core/linter.js"),
import("../src/core/a11y.js"),
];
async function domReady() {

107
src/core/a11y.js Normal file
View File

@ -0,0 +1,107 @@
// @ts-check
/**
* Module: core/a11y
* Lints for accessibility issues using axe-core package.
*/
import { pub } from "./pubsubhub.js";
import { showInlineWarning } from "./utils.js";
export const name = "core/a11y";
const DISABLED_RULES = [
"color-contrast", // too slow 🐢
"landmark-one-main", // need to add a <main>, else it marks entire page as errored
"landmark-unique",
"region",
];
export async function run(conf) {
if (!conf.a11y) {
return;
}
const violations = await getViolations(conf.a11y);
for (const violation of violations) {
/**
* We're grouping by failureSummary as it contains hints to fix the issue.
* For example, with color-constrast rule, it tells about the present color
* contrast and how to fix it. If we don't group, errors will be repetitive.
* @type {Map<string, HTMLElement[]>}
*/
const groupedBySummary = new Map();
for (const node of violation.nodes) {
const { failureSummary, element } = node;
const elements =
groupedBySummary.get(failureSummary) ||
groupedBySummary.set(failureSummary, []).get(failureSummary);
elements.push(element);
}
const { id, help, description, helpUrl } = violation;
const title = `a11y/${id}: ${help}`;
for (const [failureSummary, elements] of groupedBySummary) {
const hints = formatHintsAsMarkdown(failureSummary);
const details = `\n\n${description}.\n\n${hints}. ([Learn more](${helpUrl}))`;
showInlineWarning(elements, title, title, { details });
}
}
}
/**
* @param {object} opts Options as described at https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter
*/
async function getViolations(opts) {
const options = {
rules: Object.fromEntries(
DISABLED_RULES.map(id => [id, { enabled: false }])
),
...opts,
elementRef: true,
resultTypes: ["violations"],
reporter: "v1", // v1 includes a `failureSummary`
};
let axe;
try {
axe = await importAxe();
} catch (error) {
const msg =
"Failed to load a11y linter. See developer console for details.";
pub("error", msg);
console.error(error);
return [];
}
try {
const result = await axe.run(document, options);
return result.violations;
} catch (error) {
pub("error", "Error while looking for a11y issues.");
console.error(error);
return [];
}
}
/** @returns {Promise<typeof window.axe>} */
function importAxe() {
const script = document.createElement("script");
script.classList.add("remove");
script.src = "https://unpkg.com/axe-core@3/axe.min.js";
document.head.appendChild(script);
return new Promise((resolve, reject) => {
script.onload = () => resolve(window.axe);
script.onerror = reject;
});
}
/** @param {string} text */
function formatHintsAsMarkdown(text) {
const results = [];
for (const group of text.split("\n\n")) {
const [msg, ...opts] = group.split(/^\s{2}/m);
const options = opts.map(opt => `- ${opt.trimEnd()}`).join("\n");
results.push(`${msg}${options}`);
}
return results.join("\n\n");
}

View File

@ -128,8 +128,10 @@ export function removeReSpec(doc) {
* @param {HTMLElement|HTMLElement[]} elems
* @param {String} msg message to show in warning
* @param {String=} title error message to add on each element
* @param {object} [options]
* @param {string} [options.details]
*/
export function showInlineWarning(elems, msg, title) {
export function showInlineWarning(elems, msg, title, { details } = {}) {
if (!Array.isArray(elems)) elems = [elems];
const links = elems
.map((element, i) => {
@ -137,7 +139,11 @@ export function showInlineWarning(elems, msg, title) {
return generateMarkdownLink(element, i);
})
.join(", ");
pub("warn", `${msg} at: ${links}.`);
let message = `${msg} at: ${links}.`;
if (details) {
message += `\n\n<details>${details}</details>`;
}
pub("warn", message);
console.warn(msg, elems);
}

12
src/type-helper.d.ts vendored
View File

@ -15,6 +15,15 @@ declare module "text!*" {
export default value;
}
// See: core/a11y
interface AxeViolation {
id: string;
help: string;
helpUrl: string;
description: string;
nodes: { failureSummary: string; element: HTMLElement }[];
}
declare var respecConfig: any;
interface Window {
respecVersion: string;
@ -39,6 +48,9 @@ interface Window {
};
$: JQueryStatic;
jQuery: JQueryStatic;
axe?: {
run(context: Node, options: any): Promise<{ violations: AxeViolation[] }>;
};
}
interface Document {

View File

@ -0,0 +1,60 @@
"use strict";
import { flushIframes, makeRSDoc, makeStandardOps } from "../SpecHelper.js";
describe("Core — a11y", () => {
afterAll(flushIframes);
const body = `
<section>
<h2>Test</h2>
<img
id="image-alt-1"
src="https://www.w3.org/StyleSheets/TR/2016/logos/W3C"
/>
<img
id="image-alt-2"
src="https://www.w3.org/StyleSheets/TR/2016/logos/W3C"
alt="W3C Logo"
/>
</section>
`;
it("does nothing if not configured", async () => {
const ops = makeStandardOps(null, body);
const doc = await makeRSDoc(ops);
const offendingElements = doc.querySelectorAll(".respec-offending-element");
expect(offendingElements.length).toBe(0);
});
it("does nothing if disabled", async () => {
const ops = makeStandardOps({ a11y: false }, body);
const doc = await makeRSDoc(ops);
const offendingElements = doc.querySelectorAll(".respec-offending-element");
expect(offendingElements.length).toBe(0);
});
it("runs default tests if enabled", async () => {
const ops = makeStandardOps({ a11y: true }, body);
const doc = await makeRSDoc(ops);
const offendingElements = doc.querySelectorAll(".respec-offending-element");
expect(offendingElements.length).toBe(1);
expect(offendingElements[0].id).toBe("image-alt-1");
expect(offendingElements[0].title).toContain("a11y/image-alt");
});
it("allows overriding options", async () => {
const a11yOptions = {
runOnly: ["image-alt", "landmark-one-main"],
};
const ops = makeStandardOps({ a11y: a11yOptions }, body);
const doc = await makeRSDoc(ops);
const offendingElements = doc.querySelectorAll(".respec-offending-element");
expect(offendingElements.length).toBe(2);
expect(offendingElements[0].id).toContain("a11y-landmark-one-main");
expect(offendingElements[0].localName).toBe("html");
expect(offendingElements[1].id).toBe("image-alt-1");
});
});