Skip to content

Commit fd2b44d

Browse files
committed
feat: add a basic a11y checker
1 parent da86e0d commit fd2b44d

19 files changed

Lines changed: 609 additions & 19 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "example-plugin-a11y-checker",
3+
"type": "module",
4+
"version": "0.0.0-alpha.34",
5+
"private": true,
6+
"exports": {
7+
".": "./dist/index.mjs",
8+
"./package.json": "./package.json"
9+
},
10+
"types": "./dist/index.d.mts",
11+
"files": [
12+
"dist"
13+
],
14+
"scripts": {
15+
"build:node": "tsdown --config-loader=tsx",
16+
"build": "pnpm run build:node",
17+
"play:dev": "pnpm run build && cd playground && DEBUG='vite:devtools:*' vite"
18+
},
19+
"peerDependencies": {
20+
"vite": "*"
21+
},
22+
"dependencies": {
23+
"@vitejs/devtools": "workspace:*",
24+
"@vitejs/devtools-kit": "workspace:*",
25+
"axe-core": "catalog:devtools"
26+
},
27+
"devDependencies": {
28+
"solid-js": "catalog:devtools",
29+
"tsdown": "catalog:build",
30+
"unocss": "catalog:build",
31+
"vite": "catalog:build",
32+
"vite-plugin-solid": "catalog:devtools"
33+
}
34+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>A11y Checker Playground</title>
7+
</head>
8+
<body>
9+
<div id="app"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// @unocss-include
2+
3+
/**
4+
* This component has intentional accessibility issues
5+
* for testing the A11y Checker plugin.
6+
*/
7+
export default function App() {
8+
return (
9+
<div class="min-h-screen bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100 text-slate-800 dark:from-slate-950 dark:via-slate-900 dark:to-slate-800 dark:text-slate-100">
10+
<main class="mx-auto max-w-4xl px-6 py-10">
11+
<section class="rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-xl shadow-slate-300/20 backdrop-blur dark:border-slate-700 dark:bg-slate-900/70 dark:shadow-black/25">
12+
<h1 class="m-0 text-3xl font-semibold tracking-tight">A11y Checker Playground</h1>
13+
<p class="mt-3 leading-7 text-slate-700 dark:text-slate-300">
14+
Open Vite DevTools and click the
15+
{' '}
16+
<strong>A11y Checker</strong>
17+
{' '}
18+
icon
19+
(wheelchair) to run an accessibility audit on this page.
20+
The results will appear in the
21+
{' '}
22+
<strong>Logs</strong>
23+
{' '}
24+
panel.
25+
</p>
26+
</section>
27+
28+
{/* Intentional a11y issues below */}
29+
30+
<section class="mt-6 rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-xl dark:border-slate-700 dark:bg-slate-900/70">
31+
<h2 class="text-xl font-semibold mb-4">Test Cases</h2>
32+
33+
{/* Issue: image without alt */}
34+
<div class="mb-4">
35+
<h3 class="text-sm font-medium mb-2 op50">Image without alt text</h3>
36+
<img src="https://placehold.co/200x100" width="200" height="100" />
37+
</div>
38+
39+
{/* Issue: button with no accessible name */}
40+
<div class="mb-4">
41+
<h3 class="text-sm font-medium mb-2 op50">Button without label</h3>
42+
<button class="px-3 py-1 rounded bg-blue-500 text-white" />
43+
</div>
44+
45+
{/* Issue: low contrast text */}
46+
<div class="mb-4">
47+
<h3 class="text-sm font-medium mb-2 op50">Low contrast text</h3>
48+
<p style={{ 'color': '#ccc', 'background-color': '#fff' }}>
49+
This text has very low contrast and is hard to read.
50+
</p>
51+
</div>
52+
53+
{/* Issue: form input without label */}
54+
<div class="mb-4">
55+
<h3 class="text-sm font-medium mb-2 op50">Input without label</h3>
56+
<input type="text" placeholder="Enter something..." class="border rounded px-2 py-1" />
57+
</div>
58+
59+
{/* Issue: clickable div without role */}
60+
<div class="mb-4">
61+
<h3 class="text-sm font-medium mb-2 op50">Clickable div without role</h3>
62+
<div
63+
onClick={() => {}}
64+
class="cursor-pointer bg-purple-100 dark:bg-purple-900 rounded px-3 py-2 inline-block"
65+
>
66+
Click me (I'm a div, not a button)
67+
</div>
68+
</div>
69+
</section>
70+
</main>
71+
</div>
72+
)
73+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* @refresh reload */
2+
import { render } from 'solid-js/web'
3+
import App from './App'
4+
import '@unocss/reset/tailwind.css'
5+
import 'virtual:uno.css'
6+
7+
const root = document.getElementById('app')
8+
if (!root)
9+
throw new Error('Missing #app root')
10+
11+
render(() => <App />, root)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="vite/client" />
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { DevTools } from '@vitejs/devtools'
3+
import UnoCSS from 'unocss/vite'
4+
import { defineConfig } from 'vite'
5+
import solid from 'vite-plugin-solid'
6+
import a11yChecker from '../src/node'
7+
8+
const unoConfig = fileURLToPath(new URL('../uno.config.ts', import.meta.url))
9+
10+
export default defineConfig({
11+
plugins: [
12+
DevTools({
13+
builtinDevTools: false,
14+
}),
15+
solid(),
16+
a11yChecker(),
17+
UnoCSS({
18+
configFile: unoConfig,
19+
}),
20+
],
21+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { DevToolsLogLevel } from '@vitejs/devtools-kit'
2+
import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client'
3+
import axe from 'axe-core'
4+
5+
const SOURCE = 'a11y-checker'
6+
7+
function impactToLevel(impact: string | undefined | null): DevToolsLogLevel {
8+
switch (impact) {
9+
case 'critical':
10+
case 'serious':
11+
return 'error'
12+
case 'moderate':
13+
return 'warn'
14+
case 'minor':
15+
default:
16+
return 'info'
17+
}
18+
}
19+
20+
async function runA11yCheck(): Promise<void> {
21+
const rpc = await getDevToolsRpcClient()
22+
23+
try {
24+
const results = await axe.run(document)
25+
26+
for (const violation of results.violations) {
27+
const level = impactToLevel(violation.impact)
28+
const firstNode = violation.nodes[0]
29+
30+
await rpc.call('devtoolskit:internal:logs:add', {
31+
message: violation.description,
32+
level,
33+
description: `${violation.help}\n\n${violation.helpUrl}`,
34+
category: 'a11y',
35+
labels: violation.tags.filter(t => t.startsWith('wcag') || t.startsWith('best-practice')),
36+
elementPosition: firstNode
37+
? {
38+
selector: firstNode.target.join(' '),
39+
description: firstNode.html,
40+
}
41+
: undefined,
42+
}, SOURCE)
43+
}
44+
45+
const violationCount = results.violations.length
46+
const passCount = results.passes.length
47+
48+
await rpc.call('devtoolskit:internal:logs:add', {
49+
message: violationCount > 0
50+
? `Found ${violationCount} violation${violationCount > 1 ? 's' : ''}, ${passCount} passed`
51+
: `All ${passCount} checks passed`,
52+
level: (violationCount > 0 ? 'warn' : 'success') as DevToolsLogLevel,
53+
category: 'a11y',
54+
notify: true,
55+
autoDismiss: 4000,
56+
}, SOURCE)
57+
}
58+
catch (err) {
59+
await rpc.call('devtoolskit:internal:logs:add', {
60+
message: 'A11y audit failed',
61+
level: 'error' as DevToolsLogLevel,
62+
description: String(err),
63+
category: 'a11y',
64+
notify: true,
65+
}, SOURCE)
66+
}
67+
}
68+
69+
// Auto-execute after page load
70+
if (document.readyState === 'complete') {
71+
runA11yCheck()
72+
}
73+
else {
74+
window.addEventListener('load', () => runA11yCheck())
75+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './plugin'
2+
export * from './plugin'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { PluginWithDevTools } from '@vitejs/devtools-kit'
2+
import fs from 'node:fs'
3+
import { fileURLToPath } from 'node:url'
4+
import { normalizePath } from 'vite'
5+
6+
export function createA11yCheckerPlugin(): PluginWithDevTools {
7+
return {
8+
name: 'plugin-a11y-checker-devtools',
9+
devtools: {
10+
setup(context) {
11+
context.logs.add({
12+
message: 'A11y Checker enabled — will run audit automatically',
13+
level: 'info',
14+
notify: true,
15+
autoDismiss: 3000,
16+
autoDelete: 10000,
17+
category: 'a11y',
18+
})
19+
},
20+
},
21+
transformIndexHtml() {
22+
const distFromBundle = fileURLToPath(new URL('./client/run-axe.js', import.meta.url))
23+
const distFromSource = fileURLToPath(new URL('../../dist/client/run-axe.js', import.meta.url))
24+
const clientScript = fs.existsSync(distFromBundle) ? distFromBundle : distFromSource
25+
26+
return [
27+
{
28+
tag: 'script',
29+
attrs: {
30+
src: `/@fs/${normalizePath(clientScript)}`,
31+
type: 'module',
32+
},
33+
injectTo: 'body',
34+
},
35+
]
36+
},
37+
}
38+
}
39+
40+
export default createA11yCheckerPlugin
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"jsx": "preserve",
5+
"jsxImportSource": "solid-js",
6+
"lib": ["ESNext", "DOM"],
7+
"module": "ESNext",
8+
"moduleResolution": "Bundler",
9+
"declaration": true,
10+
"declarationMap": true,
11+
"noEmit": true,
12+
"sourceMap": true,
13+
"esModuleInterop": true,
14+
"isolatedDeclarations": false
15+
}
16+
}

0 commit comments

Comments
 (0)