feat(core/a11y): add axe-core integration (#2779)
This commit is contained in:
parent
f873b724b6
commit
3e52340aee
|
@ -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;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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");
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue