From 45472f2fe723670ac986caa174f7dd2c325457b9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 30 Jun 2022 10:54:32 +0200 Subject: [PATCH] chore(axe-core): add a11y reports in dev environement --- app/javascript/entrypoints/axe-core.ts | 156 ++++++++++++++++++++++++ app/views/layouts/application.html.haml | 2 + package.json | 1 + yarn.lock | 5 + 4 files changed, 164 insertions(+) create mode 100644 app/javascript/entrypoints/axe-core.ts diff --git a/app/javascript/entrypoints/axe-core.ts b/app/javascript/entrypoints/axe-core.ts new file mode 100644 index 000000000..4ca470d6e --- /dev/null +++ b/app/javascript/entrypoints/axe-core.ts @@ -0,0 +1,156 @@ +import type { AxeResults, NodeResult, RelatedNode } from 'axe-core'; +import axe from 'axe-core'; + +domReady().then(() => { + axe.run(document.body, { reporter: 'v2' }).then((results) => { + logToConsole(results); + }); +}); + +// contrasted against Chrome default color of #ffffff +const lightTheme = { + serious: '#d93251', + minor: '#d24700', + text: 'black' +}; + +// contrasted against Safari dark mode color of #535353 +const darkTheme = { + serious: '#ffb3b3', + minor: '#ffd500', + text: 'white' +}; + +const theme = + window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches + ? darkTheme + : lightTheme; + +const boldCourier = 'font-weight:bold;font-family:Courier;'; +const critical = `color:${theme.serious};font-weight:bold;`; +const serious = `color:${theme.serious};font-weight:normal;`; +const moderate = `color:${theme.minor};font-weight:bold;`; +const minor = `color:${theme.minor};font-weight:normal;`; +const defaultReset = `font-color:${theme.text};font-weight:normal;`; + +function logToConsole(results: AxeResults): void { + console.group('%cNew axe issues', serious); + results.violations.forEach((result) => { + let fmt: string; + switch (result.impact) { + case 'critical': + fmt = critical; + break; + case 'serious': + fmt = serious; + break; + case 'moderate': + fmt = moderate; + break; + case 'minor': + fmt = minor; + break; + default: + fmt = minor; + break; + } + console.groupCollapsed( + '%c%s: %c%s %s', + fmt, + result.impact, + defaultReset, + result.help, + result.helpUrl + ); + result.nodes.forEach((node) => { + failureSummary(node, 'any'); + failureSummary(node, 'none'); + }); + console.groupEnd(); + }); + console.groupEnd(); +} + +function failureSummary(node: NodeResult, key: AxeCoreNodeResultKey): void { + if (node[key].length > 0) { + logElement(node, console.groupCollapsed); + logHtml(node); + logFailureMessage(node, key); + + let relatedNodes: RelatedNode[] = []; + node[key].forEach((check) => { + relatedNodes = relatedNodes.concat(check.relatedNodes ?? []); + }); + + if (relatedNodes.length > 0) { + console.groupCollapsed('Related nodes'); + relatedNodes.forEach((relatedNode) => { + logElement(relatedNode, console.log); + logHtml(relatedNode); + }); + console.groupEnd(); + } + + console.groupEnd(); + } +} + +function logFailureMessage(node: NodeResult, key: AxeCoreNodeResultKey): void { + // this exists on axe but we don't export it as part of the typescript + // namespace, so just let me use it as I need + const message: string = ( + axe as unknown as AxeWithAudit + )._audit.data.failureSummaries[key].failureMessage( + node[key].map((check) => check.message || '') + ); + + console.error(message); +} + +function logElement( + node: NodeResult | RelatedNode, + logFn: (...args: unknown[]) => void +): void { + const el = document.querySelector(node.target.toString()); + if (!el) { + logFn('Selector: %c%s', boldCourier, node.target.toString()); + } else { + logFn('Element: %o', el); + } +} + +function logHtml(node: NodeResult | RelatedNode): void { + console.log('HTML: %c%s', boldCourier, node.html); +} + +type AxeCoreNodeResultKey = 'any' | 'all' | 'none'; + +interface AxeWithAudit { + _audit: { + data: { + failureSummaries: { + any: { + failureMessage: (args: string[]) => string; + }; + all: { + failureMessage: (args: string[]) => string; + }; + none: { + failureMessage: (args: string[]) => string; + }; + }; + }; + }; +} + +function domReady() { + return new Promise((resolve) => { + if (document.readyState == 'loading') { + document.addEventListener('DOMContentLoaded', () => resolve(), { + once: true + }); + } else { + resolve(); + } + }); +} diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 3f2ba2a9b..df26641eb 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -48,6 +48,8 @@ - if content_for?(:footer) = content_for(:footer) + - if Rails.env.development? + = vite_typescript_tag 'axe-core' = yield :charts_js // Container for custom turbo-stream actions diff --git a/package.json b/package.json index c911e4fe4..f994142b5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@vitejs/plugin-legacy": "^1.2.3", "@vitejs/plugin-react": "^1.1.4", "@vitejs/plugin-react-refresh": "^1.3.0", + "axe-core": "^4.4.2", "del-cli": "^4.0.1", "eslint": "^8.17.0", "eslint-config-prettier": "^8.5.0", diff --git a/yarn.lock b/yarn.lock index c4b5f9d69..bdec5d8e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3107,6 +3107,11 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== +axe-core@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.2.tgz#dcf7fb6dea866166c3eab33d68208afe4d5f670c" + integrity sha512-LVAaGp/wkkgYJcjmHsoKx4juT1aQvJyPcW09MLCjVTh3V2cc6PnyempiLMNH5iMdfIX/zdbjUx2KDjMLCTdPeA== + babel-plugin-dynamic-import-node@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"