From 3232379969e9e235e68c120275be9617de8cd654 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 13 Jan 2026 09:44:38 -0800 Subject: [PATCH 01/16] wip: add A11y Devtools plugin with accessibility auditing features --- docs/plugins/a11y.md | 336 ++++++++++ examples/react/a11y-devtools/index.html | 16 + examples/react/a11y-devtools/package.json | 35 + examples/react/a11y-devtools/src/App.tsx | 167 +++++ examples/react/a11y-devtools/src/index.tsx | 25 + examples/react/a11y-devtools/tsconfig.json | 27 + examples/react/a11y-devtools/vite.config.ts | 6 + packages/devtools-a11y/package.json | 91 +++ packages/devtools-a11y/src/config.ts | 88 +++ packages/devtools-a11y/src/event-client.ts | 22 + packages/devtools-a11y/src/export/index.ts | 180 +++++ packages/devtools-a11y/src/index.ts | 64 ++ .../devtools-a11y/src/overlay/highlight.ts | 359 ++++++++++ packages/devtools-a11y/src/overlay/index.ts | 8 + packages/devtools-a11y/src/plugin.ts | 614 ++++++++++++++++++ .../src/react/A11yDevtoolsPanel.tsx | 606 +++++++++++++++++ packages/devtools-a11y/src/react/hooks.ts | 185 ++++++ packages/devtools-a11y/src/react/index.ts | 33 + packages/devtools-a11y/src/scanner/audit.ts | 359 ++++++++++ packages/devtools-a11y/src/scanner/index.ts | 11 + .../devtools-a11y/src/scanner/live-monitor.ts | 221 +++++++ packages/devtools-a11y/src/types.ts | 222 +++++++ packages/devtools-a11y/tests/config.test.ts | 149 +++++ packages/devtools-a11y/tests/export.test.ts | 207 ++++++ packages/devtools-a11y/tsconfig.json | 7 + packages/devtools-a11y/vite.config.ts | 25 + packages/devtools/src/tabs/plugin-registry.ts | 18 + pnpm-lock.yaml | 55 ++ 28 files changed, 4136 insertions(+) create mode 100644 docs/plugins/a11y.md create mode 100644 examples/react/a11y-devtools/index.html create mode 100644 examples/react/a11y-devtools/package.json create mode 100644 examples/react/a11y-devtools/src/App.tsx create mode 100644 examples/react/a11y-devtools/src/index.tsx create mode 100644 examples/react/a11y-devtools/tsconfig.json create mode 100644 examples/react/a11y-devtools/vite.config.ts create mode 100644 packages/devtools-a11y/package.json create mode 100644 packages/devtools-a11y/src/config.ts create mode 100644 packages/devtools-a11y/src/event-client.ts create mode 100644 packages/devtools-a11y/src/export/index.ts create mode 100644 packages/devtools-a11y/src/index.ts create mode 100644 packages/devtools-a11y/src/overlay/highlight.ts create mode 100644 packages/devtools-a11y/src/overlay/index.ts create mode 100644 packages/devtools-a11y/src/plugin.ts create mode 100644 packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx create mode 100644 packages/devtools-a11y/src/react/hooks.ts create mode 100644 packages/devtools-a11y/src/react/index.ts create mode 100644 packages/devtools-a11y/src/scanner/audit.ts create mode 100644 packages/devtools-a11y/src/scanner/index.ts create mode 100644 packages/devtools-a11y/src/scanner/live-monitor.ts create mode 100644 packages/devtools-a11y/src/types.ts create mode 100644 packages/devtools-a11y/tests/config.test.ts create mode 100644 packages/devtools-a11y/tests/export.test.ts create mode 100644 packages/devtools-a11y/tsconfig.json create mode 100644 packages/devtools-a11y/vite.config.ts diff --git a/docs/plugins/a11y.md b/docs/plugins/a11y.md new file mode 100644 index 00000000..d164f91c --- /dev/null +++ b/docs/plugins/a11y.md @@ -0,0 +1,336 @@ +--- +title: Accessibility Plugin +id: a11y-plugin +--- + +The TanStack Devtools Accessibility (A11y) Plugin provides real-time accessibility auditing for your web applications, powered by [axe-core](https://github.com/dequelabs/axe-core). It helps you identify and fix accessibility issues during development. + +## Features + +- **Full Page Scanning** - Audit your entire page for accessibility violations +- **Component-Level Scanning** - Scope audits to specific components using React hooks +- **Live Monitoring** - Automatically re-scan when the DOM changes +- **Visual Overlays** - Highlight problematic elements with severity-based colors +- **Click-to-Navigate** - Click on an issue to automatically scroll to and highlight the element +- **Dark Mode Support** - Automatically adapts to the devtools theme +- **Devtools-Aware** - Automatically excludes devtools panels from scanning +- **Configurable Rule Sets** - Support for WCAG 2.0/2.1/2.2 (A/AA/AAA), Section 508, and best practices +- **Export Reports** - Download results as JSON or CSV +- **Persistent Settings** - Configuration saved to localStorage + +## Installation + +```bash +npm install @tanstack/devtools-a11y +# or +pnpm add @tanstack/devtools-a11y +# or +yarn add @tanstack/devtools-a11y +``` + +## Quick Start (React) + +```tsx +import { createRoot } from 'react-dom/client' +import { TanStackDevtools } from '@tanstack/react-devtools' +import { A11yDevtoolsPanel } from '@tanstack/devtools-a11y/react' + +createRoot(document.getElementById('root')!).render( + <> + + ( + + ), + }, + ]} + /> + , +) +``` + +## Dark Mode Support + +The A11yDevtoolsPanel automatically adapts to the devtools theme when you use the function form of the `render` prop: + +```tsx +// Receives theme ('light' | 'dark') from devtools +render: (_el, theme) => +``` + +If you don't need theme integration, you can use the simpler JSX form: + +```tsx +render: // Defaults to light theme +``` + +## Click-to-Navigate + +When you click on an issue in the panel, the plugin will: + +1. **Scroll** the problematic element into view (centered in the viewport) +2. **Highlight** the element with a pulsing overlay matching its severity color +3. **Show a tooltip** with the rule ID and impact level + +This makes it easy to locate and inspect issues directly on the page. + +## Panel Configuration + +The `A11yDevtoolsPanel` component accepts the following props: + +```tsx +interface A11yDevtoolsPanelProps { + /** Default WCAG standard to use */ + defaultStandard?: 'wcag2a' | 'wcag2aa' | 'wcag2aaa' | 'wcag21a' | 'wcag21aa' | 'wcag21aaa' | 'wcag22aa' | 'section508' | 'best-practice' + + /** Enable live monitoring by default */ + defaultLiveMonitoring?: boolean + + /** Show overlays by default */ + defaultShowOverlays?: boolean + + /** Enable scoped scanning by default */ + defaultScopedMode?: boolean + + /** Auto-scan on panel mount */ + autoScan?: boolean + + /** Custom axe-core rules to include */ + includeRules?: string[] + + /** Custom axe-core rules to exclude */ + excludeRules?: string[] + + /** CSS selectors to exclude from scanning */ + excludeSelectors?: string[] +} +``` + +### Example with Configuration + +```tsx + +``` + +## Severity Levels + +Issues are categorized by impact level with corresponding overlay colors: + +| Impact | Color | Description | +|--------|-------|-------------| +| Critical | Red | Must be fixed - prevents users from accessing content | +| Serious | Orange | Should be fixed - significantly impacts user experience | +| Moderate | Yellow | Consider fixing - affects some users | +| Minor | Blue | Optional improvement - minor impact | + +## React Hooks + +The package provides React hooks for component-level accessibility scanning. + +### useA11yAudit + +Run accessibility audits programmatically: + +```tsx +import { useA11yAudit } from '@tanstack/devtools-a11y/react' + +function MyComponent() { + const { audit, result, isScanning, error } = useA11yAudit() + + return ( +
+ + {result && ( +
+ Found {result.issues.length} issues +
+ )} +
+ ) +} +``` + +### useA11yRef + +Scope audits to a specific element: + +```tsx +import { useA11yRef } from '@tanstack/devtools-a11y/react' + +function MyForm() { + const { ref, audit, result } = useA11yRef() + + return ( +
+ + +
+ ) +} +``` + +### useA11yOverlay + +Control overlay visibility for specific elements: + +```tsx +import { useA11yOverlay } from '@tanstack/devtools-a11y/react' + +function MyComponent() { + const { showOverlays, hideOverlays, toggleOverlays, isVisible } = useA11yOverlay() + + return ( + + ) +} +``` + +## Vanilla JavaScript API + +For non-React applications, use the vanilla JavaScript plugin: + +```ts +import { createA11yPlugin } from '@tanstack/devtools-a11y' + +const plugin = createA11yPlugin({ + standard: 'wcag21aa', + liveMonitoring: true, + showOverlays: true, +}) + +// Run a scan +const result = await plugin.scan() + +// Start live monitoring +plugin.startLiveMonitoring() + +// Stop live monitoring +plugin.stopLiveMonitoring() + +// Subscribe to scan results +plugin.onScan((result) => { + console.log('Issues found:', result.issues.length) +}) + +// Clean up +plugin.destroy() +``` + +## Export Formats + +### JSON Export + +```ts +import { exportToJSON } from '@tanstack/devtools-a11y' + +const jsonString = exportToJSON(auditResult) +``` + +### CSV Export + +```ts +import { exportToCSV } from '@tanstack/devtools-a11y' + +const csvString = exportToCSV(auditResult) +``` + +## Supported Standards + +The plugin supports the following accessibility standards: + +- **WCAG 2.0** Level A, AA, AAA +- **WCAG 2.1** Level A, AA, AAA +- **WCAG 2.2** Level AA +- **Section 508** +- **Best Practices** (non-standard recommendations) + +## Types + +```ts +interface A11yIssue { + id: string + ruleId: string + impact: 'critical' | 'serious' | 'moderate' | 'minor' + description: string + help: string + helpUrl: string + selector: string + html: string + failureSummary: string + tags: string[] +} + +interface A11yAuditResult { + timestamp: number + url: string + standard: string + issues: A11yIssue[] + passes: number + violations: number + incomplete: number + scanDuration: number +} + +interface A11yConfig { + standard: string + liveMonitoring: boolean + showOverlays: boolean + scopedMode: boolean + scopeSelector?: string + excludeSelectors: string[] + includeRules: string[] + excludeRules: string[] +} +``` + +## Performance Considerations + +- **Live monitoring** uses a debounced MutationObserver to avoid excessive re-scanning +- **Scoped scanning** is recommended for large pages to reduce scan time +- **Exclude selectors** can skip third-party widgets or known-good sections +- The first scan may be slower as axe-core initializes + +## Troubleshooting + +### Issues not appearing + +1. Check that the element is visible in the viewport +2. Ensure the element is not excluded by `excludeSelectors` +3. Verify the selected standard includes the relevant rule + +### Overlays not showing + +1. Confirm overlays are enabled in the panel settings +2. Check for CSS conflicts with `z-index` or `pointer-events` +3. Ensure the container element exists in the DOM + +### Performance issues + +1. Disable live monitoring for large/complex pages +2. Use scoped scanning to limit the audit area +3. Increase the debounce delay for live monitoring + +## Example + +See the full working example at: +`examples/react/a11y-devtools/` + +Run it with: +```bash +cd examples/react/a11y-devtools +pnpm dev +``` diff --git a/examples/react/a11y-devtools/index.html b/examples/react/a11y-devtools/index.html new file mode 100644 index 00000000..589c473e --- /dev/null +++ b/examples/react/a11y-devtools/index.html @@ -0,0 +1,16 @@ + + + + + + + + + A11y Devtools - TanStack Devtools + + + +
+ + + diff --git a/examples/react/a11y-devtools/package.json b/examples/react/a11y-devtools/package.json new file mode 100644 index 00000000..79d105b8 --- /dev/null +++ b/examples/react/a11y-devtools/package.json @@ -0,0 +1,35 @@ +{ + "name": "@tanstack/devtools-a11y-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3002", + "build": "vite build", + "preview": "vite preview", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/react-devtools": "workspace:*", + "@tanstack/devtools-a11y": "workspace:*", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "vite": "^7.1.7" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react/a11y-devtools/src/App.tsx b/examples/react/a11y-devtools/src/App.tsx new file mode 100644 index 00000000..6fc05340 --- /dev/null +++ b/examples/react/a11y-devtools/src/App.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react' + +/** + * Example app with intentional accessibility issues for testing the A11y devtools plugin + */ +export default function App() { + const [showModal, setShowModal] = useState(false) + + return ( +
+

A11y Devtools Demo

+

+ This page contains intentional accessibility issues to demonstrate the + A11y devtools plugin. Open the devtools panel and click "Run Audit" to + see the issues. +

+ +
+

Accessibility Issues Demo

+ + {/* Issue: Image without alt text */} +
+

1. Image without alt text

+ +
+ + {/* Issue: Button without accessible name */} +
+

2. Button without accessible name

+ +
+ + {/* Issue: Form input without label */} +
+

3. Form input without label

+ +
+ + {/* Issue: Low color contrast */} +
+

4. Low color contrast

+

+ This text has poor color contrast and may be hard to read. +

+
+ + {/* Issue: Link without discernible text */} +
+

5. Link without discernible text

+ + + +
+ + {/* Issue: Missing main landmark */} +
+

6. Click handler on non-interactive element

+
setShowModal(true)} + style={{ + padding: '12px 24px', + backgroundColor: '#0ea5e9', + color: 'white', + borderRadius: '4px', + display: 'inline-block', + cursor: 'pointer', + }} + > + Click me (not a button!) +
+
+ + {/* Issue: Empty heading */} +
+

7. Empty heading

+

+
+ + {/* Issue: Missing form labels */} +
+

8. Select without label

+ +
+
+ +
+

Accessible Content (for comparison)

+ +
+

Proper image with alt text

+ Placeholder image for demonstration +
+ +
+

Proper button with label

+ +
+ +
+

Proper input with label

+ + +
+
+ + {showModal && ( +
+ +

This is a modal that was triggered by a non-button element.

+ +
+ )} +
+ ) +} diff --git a/examples/react/a11y-devtools/src/index.tsx b/examples/react/a11y-devtools/src/index.tsx new file mode 100644 index 00000000..2aafd878 --- /dev/null +++ b/examples/react/a11y-devtools/src/index.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { TanStackDevtools } from '@tanstack/react-devtools' +import { A11yDevtoolsPanel } from '@tanstack/devtools-a11y/react' + +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + + ( + + ), + }, + ]} + /> + , +) diff --git a/examples/react/a11y-devtools/tsconfig.json b/examples/react/a11y-devtools/tsconfig.json new file mode 100644 index 00000000..df83593a --- /dev/null +++ b/examples/react/a11y-devtools/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@tanstack/devtools-a11y/*": ["../../../packages/devtools-a11y/src/*"] + }, + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/a11y-devtools/vite.config.ts b/examples/react/a11y-devtools/vite.config.ts new file mode 100644 index 00000000..ae745180 --- /dev/null +++ b/examples/react/a11y-devtools/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/devtools-a11y/package.json b/packages/devtools-a11y/package.json new file mode 100644 index 00000000..e459b359 --- /dev/null +++ b/packages/devtools-a11y/package.json @@ -0,0 +1,91 @@ +{ + "name": "@tanstack/devtools-a11y", + "version": "0.1.0", + "description": "Accessibility auditing plugin for TanStack Devtools powered by axe-core", + "author": "TanStack", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/devtools.git", + "directory": "packages/devtools-a11y" + }, + "homepage": "https://tanstack.com/devtools", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "devtools", + "accessibility", + "a11y", + "wcag", + "axe-core", + "audit" + ], + "type": "module", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./react": { + "import": { + "types": "./dist/esm/react/index.d.ts", + "default": "./dist/esm/react/index.js" + }, + "require": { + "types": "./dist/cjs/react/index.d.cts", + "default": "./dist/cjs/react/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=18" + }, + "files": [ + "dist/", + "src" + ], + "scripts": { + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:eslint": "eslint ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:build": "publint --strict", + "build": "vite build" + }, + "dependencies": { + "@tanstack/devtools-event-client": "workspace:*", + "axe-core": "^4.10.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "react": "^19.2.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } +} diff --git a/packages/devtools-a11y/src/config.ts b/packages/devtools-a11y/src/config.ts new file mode 100644 index 00000000..3619544e --- /dev/null +++ b/packages/devtools-a11y/src/config.ts @@ -0,0 +1,88 @@ +import type { A11yPluginOptions } from './types' + +const STORAGE_KEY = 'tanstack-devtools-a11y-config' + +/** + * Default plugin configuration + */ +export const DEFAULT_CONFIG: Required = { + threshold: 'serious', + runOnMount: false, + liveMonitoring: false, + liveMonitoringDelay: 1000, + ruleSet: 'wcag21aa', + showOverlays: true, + persistSettings: true, +} + +/** + * Load configuration from localStorage + */ +export function loadConfig(): Required { + if (typeof localStorage === 'undefined') { + return DEFAULT_CONFIG + } + + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) as Partial + return { ...DEFAULT_CONFIG, ...parsed } + } + } catch (error) { + console.warn( + '[A11y Config] Failed to load config from localStorage:', + error, + ) + } + + return DEFAULT_CONFIG +} + +/** + * Save configuration to localStorage + */ +export function saveConfig(config: Partial): void { + if (typeof localStorage === 'undefined') { + return + } + + try { + const current = loadConfig() + const updated = { ...current, ...config } + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) + } catch (error) { + console.warn('[A11y Config] Failed to save config to localStorage:', error) + } +} + +/** + * Clear saved configuration + */ +export function clearConfig(): void { + if (typeof localStorage === 'undefined') { + return + } + + try { + localStorage.removeItem(STORAGE_KEY) + } catch (error) { + console.warn( + '[A11y Config] Failed to clear config from localStorage:', + error, + ) + } +} + +/** + * Merge user options with defaults + */ +export function mergeConfig( + options: A11yPluginOptions = {}, +): Required { + if (options.persistSettings !== false) { + const saved = loadConfig() + return { ...saved, ...options } + } + return { ...DEFAULT_CONFIG, ...options } +} diff --git a/packages/devtools-a11y/src/event-client.ts b/packages/devtools-a11y/src/event-client.ts new file mode 100644 index 00000000..e46df152 --- /dev/null +++ b/packages/devtools-a11y/src/event-client.ts @@ -0,0 +1,22 @@ +import { EventClient } from '@tanstack/devtools-event-client' +import { A11Y_PLUGIN_ID } from './types' +import type { A11yEventMap } from './types' + +/** + * Event client for the A11y devtools plugin. + * Handles communication between the devtools panel and the page. + */ +class A11yEventClient extends EventClient { + constructor() { + super({ + pluginId: A11Y_PLUGIN_ID, + debug: process.env.NODE_ENV === 'development', + }) + } +} + +/** + * Singleton instance of the A11y event client. + * Use this to emit and listen for a11y-related events. + */ +export const a11yEventClient = new A11yEventClient() diff --git a/packages/devtools-a11y/src/export/index.ts b/packages/devtools-a11y/src/export/index.ts new file mode 100644 index 00000000..5305251f --- /dev/null +++ b/packages/devtools-a11y/src/export/index.ts @@ -0,0 +1,180 @@ +import type { A11yAuditResult, ExportOptions } from '../types' + +/** + * Export audit results to JSON format + */ +export function exportToJson( + result: A11yAuditResult, + _options: Partial = {}, +): string { + const exportData = { + meta: { + exportedAt: new Date().toISOString(), + url: result.url, + auditTimestamp: result.timestamp, + duration: result.duration, + context: result.context, + }, + summary: result.summary, + issues: result.issues.map((issue) => ({ + id: issue.id, + ruleId: issue.ruleId, + impact: issue.impact, + message: issue.message, + help: issue.help, + helpUrl: issue.helpUrl, + wcagTags: issue.wcagTags, + nodes: issue.nodes.map((node) => ({ + selector: node.selector, + html: node.html, + failureSummary: node.failureSummary, + })), + })), + } + + return JSON.stringify(exportData, null, 2) +} + +/** + * Export audit results to CSV format + */ +export function exportToCsv( + result: A11yAuditResult, + _options: Partial = {}, +): string { + const headers = [ + 'Rule ID', + 'Impact', + 'Message', + 'Help URL', + 'WCAG Tags', + 'Selector', + 'HTML', + ] + + const rows: Array> = [] + + for (const issue of result.issues) { + for (const node of issue.nodes) { + rows.push([ + issue.ruleId, + issue.impact, + issue.message.replace(/"/g, '""'), + issue.helpUrl, + issue.wcagTags.join('; '), + node.selector, + node.html.replace(/"/g, '""'), + ]) + } + } + + return [ + headers.map((h) => `"${h}"`).join(','), + ...rows.map((row) => row.map((cell) => `"${cell}"`).join(',')), + ].join('\n') +} + +/** + * Download a file with the given content + */ +export function downloadFile( + content: string, + filename: string, + mimeType: string, +): void { + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +/** + * Export audit results and trigger download + */ +export function exportAuditResults( + result: A11yAuditResult, + options: ExportOptions, +): void { + const { format, filename } = options + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const defaultFilename = `a11y-audit-${timestamp}` + + if (format === 'json') { + const content = exportToJson(result, options) + downloadFile( + content, + `${filename || defaultFilename}.json`, + 'application/json', + ) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (format === 'csv') { + const content = exportToCsv(result, options) + downloadFile(content, `${filename || defaultFilename}.csv`, 'text/csv') + } +} + +/** + * Generate a summary report as a formatted string + */ +export function generateSummaryReport(result: A11yAuditResult): string { + const { summary } = result + + const lines = [ + '='.repeat(50), + 'ACCESSIBILITY AUDIT REPORT', + '='.repeat(50), + '', + `URL: ${result.url}`, + `Date: ${new Date(result.timestamp).toLocaleString()}`, + `Duration: ${result.duration.toFixed(2)}ms`, + '', + '-'.repeat(50), + 'SUMMARY', + '-'.repeat(50), + '', + `Total Issues: ${summary.total}`, + ` - Critical: ${summary.critical}`, + ` - Serious: ${summary.serious}`, + ` - Moderate: ${summary.moderate}`, + ` - Minor: ${summary.minor}`, + '', + `Passing Rules: ${summary.passes}`, + `Incomplete Checks: ${summary.incomplete}`, + '', + ] + + if (result.issues.length > 0) { + lines.push('-'.repeat(50)) + lines.push('ISSUES') + lines.push('-'.repeat(50)) + lines.push('') + + const issuesByImpact = { + critical: result.issues.filter((i) => i.impact === 'critical'), + serious: result.issues.filter((i) => i.impact === 'serious'), + moderate: result.issues.filter((i) => i.impact === 'moderate'), + minor: result.issues.filter((i) => i.impact === 'minor'), + } + + for (const [impact, issues] of Object.entries(issuesByImpact)) { + if (issues.length > 0) { + lines.push(`[${impact.toUpperCase()}]`) + for (const issue of issues) { + lines.push(` - ${issue.ruleId}: ${issue.message}`) + lines.push(` Selector: ${issue.nodes[0]?.selector}`) + lines.push(` Learn more: ${issue.helpUrl}`) + lines.push('') + } + } + } + } + + lines.push('='.repeat(50)) + + return lines.join('\n') +} diff --git a/packages/devtools-a11y/src/index.ts b/packages/devtools-a11y/src/index.ts new file mode 100644 index 00000000..de7e704a --- /dev/null +++ b/packages/devtools-a11y/src/index.ts @@ -0,0 +1,64 @@ +// Core plugin +export { createA11yPlugin } from './plugin' +export type { A11yDevtoolsPlugin } from './plugin' + +// Event client +export { a11yEventClient } from './event-client' + +// Scanner +export { + runAudit, + groupIssuesByImpact, + filterByThreshold, + meetsThreshold, + diffAuditResults, + getAvailableRules, + LiveMonitor, + getLiveMonitor, +} from './scanner' + +// Overlay +export { + highlightElement, + highlightAllIssues, + clearHighlights, + initOverlayAdapter, + overlayAdapter, +} from './overlay' + +// Export utilities +export { + exportToJson, + exportToCsv, + exportAuditResults, + generateSummaryReport, +} from './export' + +// Config +export { + loadConfig, + saveConfig, + clearConfig, + mergeConfig, + DEFAULT_CONFIG, +} from './config' + +// Types +export type { + SeverityThreshold, + WCAGLevel, + RuleSetPreset, + A11yNode, + A11yIssue, + GroupedIssues, + A11ySummary, + A11yAuditResult, + A11yAuditOptions, + A11yPluginOptions, + A11yPluginState, + A11yEventMap, + ExportFormat, + ExportOptions, +} from './types' + +export { A11Y_PLUGIN_ID } from './types' diff --git a/packages/devtools-a11y/src/overlay/highlight.ts b/packages/devtools-a11y/src/overlay/highlight.ts new file mode 100644 index 00000000..5ccee39f --- /dev/null +++ b/packages/devtools-a11y/src/overlay/highlight.ts @@ -0,0 +1,359 @@ +import { a11yEventClient } from '../event-client' +import type { A11yIssue, SeverityThreshold } from '../types' + +const HIGHLIGHT_CLASS = 'tsd-a11y-highlight' +const HIGHLIGHT_STYLE_ID = 'tsd-a11y-highlight-styles' +const TOOLTIP_CLASS = 'tsd-a11y-tooltip' + +// Track active tooltips and their target elements for scroll updates +const activeTooltips = new Map() +let scrollHandler: (() => void) | null = null + +/** + * Selectors for devtools elements that should never be highlighted + */ +const DEVTOOLS_SELECTORS = [ + '[data-testid="tanstack_devtools"]', + '[data-devtools]', + '[data-devtools-panel]', + '[data-a11y-overlay]', +] + +/** + * Check if an element is inside the devtools panel + */ +function isInsideDevtools(element: Element): boolean { + for (const selector of DEVTOOLS_SELECTORS) { + if (element.closest(selector)) { + return true + } + } + return false +} + +/** + * Color scheme for different severity levels + */ +const SEVERITY_COLORS: Record< + SeverityThreshold, + { border: string; bg: string; text: string } +> = { + critical: { + border: '#dc2626', + bg: 'rgba(220, 38, 38, 0.15)', + text: '#dc2626', + }, + serious: { + border: '#ea580c', + bg: 'rgba(234, 88, 12, 0.15)', + text: '#ea580c', + }, + moderate: { + border: '#ca8a04', + bg: 'rgba(202, 138, 4, 0.15)', + text: '#ca8a04', + }, + minor: { border: '#2563eb', bg: 'rgba(37, 99, 235, 0.15)', text: '#2563eb' }, +} + +/** + * Inject overlay styles into the document + */ +function injectStyles(): void { + if (document.getElementById(HIGHLIGHT_STYLE_ID)) { + return + } + + const style = document.createElement('style') + style.id = HIGHLIGHT_STYLE_ID + // Highlights use outline which doesn't affect layout + // Tooltips use fixed positioning to avoid layout shifts + style.textContent = ` + .${HIGHLIGHT_CLASS}--critical { + outline: 3px solid ${SEVERITY_COLORS.critical.border} !important; + outline-offset: 2px !important; + background-color: ${SEVERITY_COLORS.critical.bg} !important; + } + + .${HIGHLIGHT_CLASS}--serious { + outline: 3px solid ${SEVERITY_COLORS.serious.border} !important; + outline-offset: 2px !important; + background-color: ${SEVERITY_COLORS.serious.bg} !important; + } + + .${HIGHLIGHT_CLASS}--moderate { + outline: 2px solid ${SEVERITY_COLORS.moderate.border} !important; + outline-offset: 2px !important; + background-color: ${SEVERITY_COLORS.moderate.bg} !important; + } + + .${HIGHLIGHT_CLASS}--minor { + outline: 2px dashed ${SEVERITY_COLORS.minor.border} !important; + outline-offset: 2px !important; + background-color: ${SEVERITY_COLORS.minor.bg} !important; + } + + .${HIGHLIGHT_CLASS}--pulse { + animation: tsd-a11y-pulse 1.5s ease-in-out infinite !important; + } + + @keyframes tsd-a11y-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } + } + + .${TOOLTIP_CLASS} { + position: fixed; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + font-family: system-ui, -apple-system, sans-serif; + white-space: nowrap; + z-index: 99990; + pointer-events: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + .${TOOLTIP_CLASS}--critical { + background: ${SEVERITY_COLORS.critical.border}; + color: white; + } + + .${TOOLTIP_CLASS}--serious { + background: ${SEVERITY_COLORS.serious.border}; + color: white; + } + + .${TOOLTIP_CLASS}--moderate { + background: ${SEVERITY_COLORS.moderate.border}; + color: white; + } + + .${TOOLTIP_CLASS}--minor { + background: ${SEVERITY_COLORS.minor.border}; + color: white; + } + ` + document.head.appendChild(style) +} + +/** + * Update all tooltip positions based on their target elements + */ +function updateTooltipPositions(): void { + activeTooltips.forEach((targetElement, tooltip) => { + const rect = targetElement.getBoundingClientRect() + tooltip.style.top = `${rect.top - 28}px` + tooltip.style.left = `${rect.left}px` + }) +} + +/** + * Start listening for scroll events to update tooltip positions + */ +function startScrollListener(): void { + if (scrollHandler) return + + scrollHandler = () => { + requestAnimationFrame(updateTooltipPositions) + } + + window.addEventListener('scroll', scrollHandler, true) // capture phase to catch all scrolls +} + +/** + * Stop listening for scroll events + */ +function stopScrollListener(): void { + if (scrollHandler) { + window.removeEventListener('scroll', scrollHandler, true) + scrollHandler = null + } +} + +/** + * Create a tooltip element for an issue and position it above the target element + */ +function createTooltip( + ruleId: string, + impact: SeverityThreshold, + targetElement: Element, +): HTMLElement { + const tooltip = document.createElement('div') + tooltip.className = `${TOOLTIP_CLASS} ${TOOLTIP_CLASS}--${impact}` + tooltip.textContent = `${impact.toUpperCase()}: ${ruleId}` + // Mark as overlay element so it's excluded from a11y scans + tooltip.setAttribute('data-a11y-overlay', 'true') + + // Position the tooltip above the target element using fixed positioning + const rect = targetElement.getBoundingClientRect() + tooltip.style.top = `${rect.top - 28}px` + tooltip.style.left = `${rect.left}px` + + // Track this tooltip for scroll updates + activeTooltips.set(tooltip, targetElement) + + // Start scroll listener if not already running + if (activeTooltips.size === 1) { + startScrollListener() + } + + return tooltip +} + +/** + * Highlight a single element with the specified severity + */ +export function highlightElement( + selector: string, + impact: SeverityThreshold = 'serious', + options: { pulse?: boolean; showTooltip?: boolean; ruleId?: string } = {}, +): void { + const { pulse = false, showTooltip = true, ruleId } = options + + try { + injectStyles() + + const elements = document.querySelectorAll(selector) + if (elements.length === 0) { + console.warn(`[A11y Overlay] No elements found for selector: ${selector}`) + return + } + + let highlightedCount = 0 + elements.forEach((el) => { + // Skip elements inside devtools + if (isInsideDevtools(el)) { + return + } + + el.classList.add(HIGHLIGHT_CLASS, `${HIGHLIGHT_CLASS}--${impact}`) + + if (pulse) { + el.classList.add(`${HIGHLIGHT_CLASS}--pulse`) + } + + // Add tooltip to first highlighted element only + if (showTooltip && highlightedCount === 0 && ruleId) { + const tooltip = createTooltip(ruleId, impact, el) + // Append tooltip to body with fixed positioning instead of to the element + document.body.appendChild(tooltip) + } + + // Scroll first highlighted element into view + if (highlightedCount === 0) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + + highlightedCount++ + }) + + if (highlightedCount > 0) { + console.log( + `[A11y Overlay] Highlighted ${highlightedCount} element(s) with selector: ${selector}`, + ) + } + } catch (error) { + console.error('[A11y Overlay] Error highlighting element:', error) + } +} + +/** + * Highlight all elements with issues + */ +export function highlightAllIssues(issues: Array): void { + injectStyles() + clearHighlights() + + for (const issue of issues) { + for (const node of issue.nodes) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + highlightElement(node.selector, issue.impact ?? 'minor', { + pulse: false, + showTooltip: true, + ruleId: issue.ruleId, + }) + } + } +} + +/** + * Clear all highlights from the page + */ +export function clearHighlights(): void { + // Remove highlight classes + const highlighted = document.querySelectorAll(`.${HIGHLIGHT_CLASS}`) + highlighted.forEach((el) => { + el.classList.remove( + HIGHLIGHT_CLASS, + `${HIGHLIGHT_CLASS}--critical`, + `${HIGHLIGHT_CLASS}--serious`, + `${HIGHLIGHT_CLASS}--moderate`, + `${HIGHLIGHT_CLASS}--minor`, + `${HIGHLIGHT_CLASS}--pulse`, + ) + }) + + // Remove tooltips and clear tracking + const tooltips = document.querySelectorAll(`.${TOOLTIP_CLASS}`) + tooltips.forEach((el) => el.remove()) + activeTooltips.clear() + stopScrollListener() +} + +/** + * Remove styles from the document + */ +export function removeStyles(): void { + const style = document.getElementById(HIGHLIGHT_STYLE_ID) + if (style) { + style.remove() + } +} + +/** + * Initialize the overlay adapter - sets up event listeners + */ +export function initOverlayAdapter(): () => void { + injectStyles() + + const cleanupHighlight = a11yEventClient.on('highlight', (event) => { + const { selector, impact } = event.payload + clearHighlights() + highlightElement(selector, impact, { pulse: true }) + }) + + const cleanupClear = a11yEventClient.on('clear-highlights', () => { + clearHighlights() + }) + + const cleanupHighlightAll = a11yEventClient.on('highlight-all', (event) => { + highlightAllIssues(event.payload.issues) + }) + + console.log('[A11y Overlay] Adapter initialized') + + return () => { + cleanupHighlight() + cleanupClear() + cleanupHighlightAll() + clearHighlights() + removeStyles() + console.log('[A11y Overlay] Adapter destroyed') + } +} + +/** + * Overlay adapter object for direct usage + */ +export const overlayAdapter = { + highlight: highlightElement, + highlightAll: highlightAllIssues, + clear: clearHighlights, + init: initOverlayAdapter, +} diff --git a/packages/devtools-a11y/src/overlay/index.ts b/packages/devtools-a11y/src/overlay/index.ts new file mode 100644 index 00000000..6df0fecb --- /dev/null +++ b/packages/devtools-a11y/src/overlay/index.ts @@ -0,0 +1,8 @@ +export { + highlightElement, + highlightAllIssues, + clearHighlights, + removeStyles, + initOverlayAdapter, + overlayAdapter, +} from './highlight' diff --git a/packages/devtools-a11y/src/plugin.ts b/packages/devtools-a11y/src/plugin.ts new file mode 100644 index 00000000..ab5a7e0a --- /dev/null +++ b/packages/devtools-a11y/src/plugin.ts @@ -0,0 +1,614 @@ +import { a11yEventClient } from './event-client' +import { + filterByThreshold, + getLiveMonitor, + groupIssuesByImpact, + runAudit, +} from './scanner' +import { + clearHighlights, + highlightAllIssues, + initOverlayAdapter, +} from './overlay' +import { mergeConfig, saveConfig } from './config' +import { exportAuditResults } from './export' +import type { + A11yAuditResult, + A11yIssue, + A11yPluginOptions, + RuleSetPreset, + SeverityThreshold, +} from './types' + +/** + * Plugin interface compatible with TanStack Devtools + */ +export interface A11yDevtoolsPlugin { + id: string + name: string + render: (el: HTMLDivElement, theme: 'light' | 'dark') => void + destroy?: () => void +} + +/** + * Severity colors for the UI + */ +const SEVERITY_COLORS = { + critical: '#dc2626', + serious: '#ea580c', + moderate: '#ca8a04', + minor: '#2563eb', +} + +/** + * Severity labels for display + */ +const SEVERITY_LABELS: Record = { + critical: 'Critical', + serious: 'Serious', + moderate: 'Moderate', + minor: 'Minor', +} + +/** + * Create the A11y devtools plugin (vanilla JS version) + */ +export function createA11yPlugin( + opts: A11yPluginOptions = {}, +): A11yDevtoolsPlugin { + const config = mergeConfig(opts) + let currentResults: A11yAuditResult | null = null + let overlayCleanup: (() => void) | null = null + let selectedIssueId: string | null = null + + return { + id: 'devtools-a11y', + name: 'Accessibility', + + render: (el: HTMLDivElement, theme: 'light' | 'dark') => { + const isDark = theme === 'dark' + const bg = isDark ? '#1a1a2e' : '#ffffff' + const fg = isDark ? '#e2e8f0' : '#1e293b' + const borderColor = isDark ? '#374151' : '#e2e8f0' + const secondaryBg = isDark ? '#0f172a' : '#f8fafc' + + // Initialize overlay adapter + overlayCleanup = initOverlayAdapter() + + // Render initial UI + renderPanel() + + function renderPanel() { + el.innerHTML = ` +
+ +
+
+

Accessibility Audit

+ ${ + currentResults + ? ` + + ${currentResults.summary.total} issue${currentResults.summary.total !== 1 ? 's' : ''} + + ` + : '' + } +
+
+ + ${ + currentResults + ? ` + + + ` + : '' + } +
+
+ + +
+ + + +
+ + +
+ ${renderResults()} +
+
+ ` + + // Attach event handlers + attachEventHandlers() + } + + function renderResults(): string { + if (!currentResults) { + return ` +
+

No audit results yet

+

Click "Run Audit" to scan for accessibility issues

+
+ ` + } + + if (currentResults.issues.length === 0) { + return ` +
+
+

No accessibility issues found!

+

+ Scanned in ${currentResults.duration.toFixed(0)}ms +

+
+ ` + } + + const filteredIssues = filterByThreshold( + currentResults.issues, + config.threshold, + ) + const grouped = groupIssuesByImpact(filteredIssues) + + let html = ` + +
+ ${(['critical', 'serious', 'moderate', 'minor'] as const) + .map( + (impact) => ` +
+
+ ${currentResults!.summary[impact]} +
+
+ ${SEVERITY_LABELS[impact]} +
+
+ `, + ) + .join('')} +
+ ` + + // Issue list + for (const impact of [ + 'critical', + 'serious', + 'moderate', + 'minor', + ] as const) { + const issues = grouped[impact] + if (issues.length === 0) continue + + html += ` +
+

+ ${SEVERITY_LABELS[impact]} (${issues.length}) +

+ ${issues.map((issue) => renderIssueCard(issue, impact)).join('')} +
+ ` + } + + return html + } + + function renderIssueCard( + issue: A11yIssue, + impact: SeverityThreshold, + ): string { + const isSelected = selectedIssueId === issue.id + const selector = issue.nodes[0]?.selector || 'unknown' + + return ` +
+
+
+
+ + ${issue.ruleId} +
+

+ ${issue.message} +

+
+ ${selector} +
+
+ + Learn more → + +
+ ${ + issue.wcagTags.length > 0 + ? ` +
+ ${issue.wcagTags + .slice(0, 3) + .map( + (tag) => ` + + ${tag} + + `, + ) + .join('')} +
+ ` + : '' + } +
+ ` + } + + function attachEventHandlers() { + // Scan button + const scanBtn = el.querySelector( + '#scan-btn', + ) + if (scanBtn) { + scanBtn.onclick = async () => { + scanBtn.textContent = 'Scanning...' + scanBtn.disabled = true + + try { + a11yEventClient.emit('scan-start', { context: 'document' }) + + currentResults = await runAudit({ + threshold: config.threshold, + ruleSet: config.ruleSet, + }) + + a11yEventClient.emit('results', currentResults) + a11yEventClient.emit('scan-complete', { + duration: currentResults.duration, + issueCount: currentResults.issues.length, + }) + + if (config.showOverlays && currentResults.issues.length > 0) { + highlightAllIssues(currentResults.issues) + } + + renderPanel() + } catch (error) { + console.error('[A11y Plugin] Scan failed:', error) + a11yEventClient.emit('scan-error', { + error: error instanceof Error ? error.message : String(error), + }) + } + } + } + + // Export button + const exportBtn = el.querySelector( + '#export-btn', + ) + if (exportBtn && currentResults) { + exportBtn.onclick = () => { + exportAuditResults(currentResults!, { format: 'json' }) + } + } + + // Toggle overlay button + const toggleOverlayBtn = el.querySelector( + '#toggle-overlay-btn', + ) + if (toggleOverlayBtn) { + toggleOverlayBtn.onclick = () => { + config.showOverlays = !config.showOverlays + saveConfig({ showOverlays: config.showOverlays }) + + if ( + config.showOverlays && + currentResults && + currentResults.issues.length > 0 + ) { + highlightAllIssues(currentResults.issues) + } else { + clearHighlights() + } + + renderPanel() + } + } + + // Threshold select + const thresholdSelect = el.querySelector( + '#threshold-select', + ) + if (thresholdSelect) { + thresholdSelect.onchange = () => { + config.threshold = thresholdSelect.value as SeverityThreshold + saveConfig({ threshold: config.threshold }) + a11yEventClient.emit('config-change', { + threshold: config.threshold, + }) + renderPanel() + } + } + + // Rule set select + const rulesetSelect = el.querySelector( + '#ruleset-select', + ) + if (rulesetSelect) { + rulesetSelect.onchange = () => { + config.ruleSet = rulesetSelect.value as RuleSetPreset + saveConfig({ ruleSet: config.ruleSet }) + a11yEventClient.emit('config-change', { ruleSet: config.ruleSet }) + } + } + + // Live monitoring checkbox + const liveMonitorCheckbox = el.querySelector( + '#live-monitor-checkbox', + ) + if (liveMonitorCheckbox) { + liveMonitorCheckbox.onchange = () => { + config.liveMonitoring = liveMonitorCheckbox.checked + saveConfig({ liveMonitoring: config.liveMonitoring }) + + const monitor = getLiveMonitor({ + debounceMs: config.liveMonitoringDelay, + auditOptions: { + threshold: config.threshold, + ruleSet: config.ruleSet, + }, + onAuditComplete: (result) => { + currentResults = result + if (config.showOverlays && result.issues.length > 0) { + highlightAllIssues(result.issues) + } + renderPanel() + }, + }) + + if (config.liveMonitoring) { + monitor.start() + } else { + monitor.stop() + } + } + } + + // Issue card clicks + const issueCards = el.querySelectorAll('.issue-card') + issueCards.forEach((card) => { + ;(card as HTMLElement).onclick = () => { + const issueId = card.getAttribute('data-issue-id') + const selector = card.getAttribute('data-selector') + + selectedIssueId = issueId + renderPanel() + + if (selector) { + clearHighlights() + a11yEventClient.emit('highlight', { + selector, + impact: + currentResults?.issues.find((i) => i.id === issueId) + ?.impact || 'serious', + }) + } + } + }) + } + + // Run on mount if configured + if (config.runOnMount) { + const scanBtn = el.querySelector( + '#scan-btn', + ) + if (scanBtn) { + scanBtn.click() + } + } + }, + + destroy: () => { + if (overlayCleanup) { + overlayCleanup() + overlayCleanup = null + } + clearHighlights() + getLiveMonitor().stop() + currentResults = null + selectedIssueId = null + }, + } +} diff --git a/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx b/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx new file mode 100644 index 00000000..6ff2dee0 --- /dev/null +++ b/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx @@ -0,0 +1,606 @@ +import { useEffect, useState } from 'react' +import { a11yEventClient } from '../event-client' +import { filterByThreshold, getLiveMonitor, groupIssuesByImpact, runAudit, } from '../scanner' +import { clearHighlights, highlightAllIssues, highlightElement, initOverlayAdapter, } from '../overlay' +import { mergeConfig, saveConfig } from '../config' +import { exportAuditResults } from '../export' +import type { JSX } from 'react' +import type { A11yAuditResult, A11yPluginOptions, RuleSetPreset, SeverityThreshold, } from '../types' + +const SEVERITY_COLORS = { + critical: '#dc2626', + serious: '#ea580c', + moderate: '#ca8a04', + minor: '#2563eb', +} + +const SEVERITY_LABELS: Record = { + critical: 'Critical', + serious: 'Serious', + moderate: 'Moderate', + minor: 'Minor', +} + +interface A11yDevtoolsPanelProps { + options?: A11yPluginOptions + /** Theme passed from TanStack Devtools */ + theme?: 'light' | 'dark' +} + +/** + * Scroll an element into view and briefly highlight it + */ +function scrollToElement(selector: string): boolean { + try { + const element = document.querySelector(selector) + if (element) { + // Scroll the element into view + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }) + return true + } + } catch (error) { + console.warn('[A11y Panel] Could not scroll to element:', selector, error) + } + return false +} + +/** + * React component for the A11y devtools panel + */ +export function A11yDevtoolsPanel({ + options = {}, + theme = 'light', +}: A11yDevtoolsPanelProps): JSX.Element { + const [config, setConfig] = useState(() => mergeConfig(options)) + const [results, setResults] = useState(null) + const [isScanning, setIsScanning] = useState(false) + const [selectedIssueId, setSelectedIssueId] = useState(null) + + const isDark = theme === 'dark' + const bg = isDark ? '#1a1a2e' : '#ffffff' + const fg = isDark ? '#e2e8f0' : '#1e293b' + const borderColor = isDark ? '#374151' : '#e2e8f0' + const secondaryBg = isDark ? '#0f172a' : '#f8fafc' + + // Initialize overlay adapter + useEffect(() => { + return initOverlayAdapter() + }, []) + + // Run on mount if configured + useEffect(() => { + if (config.runOnMount) { + handleScan() + } + }, []) + + const handleScan = async () => { + setIsScanning(true) + + try { + a11yEventClient.emit('scan-start', { context: 'document' }) + + const result = await runAudit({ + threshold: config.threshold, + ruleSet: config.ruleSet, + }) + + setResults(result) + a11yEventClient.emit('results', result) + a11yEventClient.emit('scan-complete', { + duration: result.duration, + issueCount: result.issues.length, + }) + + if (config.showOverlays && result.issues.length > 0) { + highlightAllIssues(result.issues) + } + } catch (error) { + console.error('[A11y Panel] Scan failed:', error) + a11yEventClient.emit('scan-error', { + error: error instanceof Error ? error.message : String(error), + }) + } finally { + setIsScanning(false) + } + } + + const handleExport = () => { + if (results) { + exportAuditResults(results, { format: 'json' }) + } + } + + const handleToggleOverlays = () => { + const newValue = !config.showOverlays + setConfig((prev) => ({ ...prev, showOverlays: newValue })) + saveConfig({ showOverlays: newValue }) + + if (newValue && results && results.issues.length > 0) { + highlightAllIssues(results.issues) + } else { + clearHighlights() + } + } + + const handleThresholdChange = (threshold: SeverityThreshold) => { + setConfig((prev) => ({ ...prev, threshold })) + saveConfig({ threshold }) + a11yEventClient.emit('config-change', { threshold }) + } + + const handleRuleSetChange = (ruleSet: RuleSetPreset) => { + setConfig((prev) => ({ ...prev, ruleSet })) + saveConfig({ ruleSet }) + a11yEventClient.emit('config-change', { ruleSet }) + } + + const handleLiveMonitoringChange = (enabled: boolean) => { + setConfig((prev) => ({ ...prev, liveMonitoring: enabled })) + saveConfig({ liveMonitoring: enabled }) + + const monitor = getLiveMonitor({ + debounceMs: config.liveMonitoringDelay, + auditOptions: { + threshold: config.threshold, + ruleSet: config.ruleSet, + }, + onAuditComplete: (result) => { + setResults(result) + if (config.showOverlays && result.issues.length > 0) { + highlightAllIssues(result.issues) + } + }, + }) + + if (enabled) { + monitor.start() + } else { + monitor.stop() + } + } + + const handleIssueClick = (issueId: string, selector: string) => { + setSelectedIssueId(issueId) + clearHighlights() + const issue = results?.issues.find((i) => i.id === issueId) + if (issue) { + // Scroll to the element first + scrollToElement(selector) + + // Highlight the element + highlightElement(selector, issue.impact) + + // Emit event for other listeners + a11yEventClient.emit('highlight', { + selector, + impact: issue.impact, + }) + } + } + + const filteredIssues = results + ? filterByThreshold(results.issues, config.threshold) + : [] + const grouped = groupIssuesByImpact(filteredIssues) + + return ( +
+ {/* Header */} +
+
+

+ Accessibility Audit +

+ {results && ( + + {results.summary.total} issue + {results.summary.total !== 1 ? 's' : ''} + + )} +
+
+ + {results && ( + <> + + + + )} +
+
+ + {/* Config Bar */} +
+ + + +
+ + {/* Results Area */} +
+ {!results && ( +
+

+ No audit results yet +

+

+ Click "Run Audit" to scan for accessibility issues +

+
+ )} + + {results && results.issues.length === 0 && ( +
+
+

+ No accessibility issues found! +

+

+ Scanned in {results.duration.toFixed(0)}ms +

+
+ )} + + {results && filteredIssues.length > 0 && ( + <> + {/* Summary */} +
+ {(['critical', 'serious', 'moderate', 'minor'] as const).map( + (impact) => ( +
+
+ {results.summary[impact]} +
+
+ {SEVERITY_LABELS[impact]} +
+
+ ), + )} +
+ + {/* Issue List */} + {(['critical', 'serious', 'moderate', 'minor'] as const).map( + (impact) => { + const issues = grouped[impact] + if (issues.length === 0) return null + + return ( +
+

+ {SEVERITY_LABELS[impact]} ({issues.length}) +

+ {issues.map((issue) => { + const selector = issue.nodes[0]?.selector || 'unknown' + const isSelected = selectedIssueId === issue.id + + return ( +
handleIssueClick(issue.id, selector)} + style={{ + padding: '12px', + marginBottom: '8px', + background: isSelected + ? isDark + ? '#1e3a5f' + : '#e0f2fe' + : secondaryBg, + border: `1px solid ${isSelected ? '#0ea5e9' : borderColor}`, + borderRadius: '6px', + cursor: 'pointer', + }} + > + + {issue.wcagTags.length > 0 && ( +
+ {issue.wcagTags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ) + })} +
+ ) + }, + )} + + )} +
+
+ ) +} diff --git a/packages/devtools-a11y/src/react/hooks.ts b/packages/devtools-a11y/src/react/hooks.ts new file mode 100644 index 00000000..b7230f6b --- /dev/null +++ b/packages/devtools-a11y/src/react/hooks.ts @@ -0,0 +1,185 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { a11yEventClient } from '../event-client' +import { getLiveMonitor, runAudit } from '../scanner' +import { clearHighlights, highlightAllIssues, initOverlayAdapter, } from '../overlay' +import type { A11yAuditOptions, A11yAuditResult, A11yPluginOptions, } from '../types' + +/** + * Hook to run accessibility audits on a component + */ +export function useA11yAudit(options: A11yAuditOptions = {}) { + const [results, setResults] = useState(null) + const [isScanning, setIsScanning] = useState(false) + const [error, setError] = useState(null) + + const scan = useCallback( + async (scanOptions?: Partial) => { + setIsScanning(true) + setError(null) + + try { + const result = await runAudit({ ...options, ...scanOptions }) + setResults(result) + a11yEventClient.emit('results', result) + return result + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + setError(errorMessage) + a11yEventClient.emit('scan-error', { error: errorMessage }) + return null + } finally { + setIsScanning(false) + } + }, + [options], + ) + + return { + results, + isScanning, + error, + scan, + } +} + +/** + * Hook to audit a specific element ref + */ +export function useA11yRef( + options: A11yAuditOptions = {}, +) { + const ref = useRef(null) + const [results, setResults] = useState(null) + const [isScanning, setIsScanning] = useState(false) + + const scan = useCallback(async () => { + if (!ref.current) { + console.warn('[useA11yRef] No element ref available') + return null + } + + setIsScanning(true) + + try { + const result = await runAudit({ + ...options, + context: ref.current, + }) + setResults(result) + return result + } catch (err) { + console.error('[useA11yRef] Scan failed:', err) + return null + } finally { + setIsScanning(false) + } + }, [options]) + + return { + ref, + results, + isScanning, + scan, + } +} + +/** + * Hook to initialize the overlay adapter + */ +export function useA11yOverlay() { + useEffect(() => { + return initOverlayAdapter() + }, []) + + return { + highlight: ( + selector: string, + impact: 'critical' | 'serious' | 'moderate' | 'minor', + ) => { + a11yEventClient.emit('highlight', { selector, impact }) + }, + highlightAll: highlightAllIssues, + clear: clearHighlights, + } +} + +/** + * Hook for live monitoring + */ +export function useA11yLiveMonitor( + options: A11yPluginOptions & { enabled?: boolean } = {}, +) { + const { enabled = false, ...monitorOptions } = options + const [results, setResults] = useState(null) + const [isActive, setIsActive] = useState(false) + + useEffect(() => { + if (!enabled) return + + const monitor = getLiveMonitor({ + debounceMs: monitorOptions.liveMonitoringDelay, + auditOptions: { + threshold: monitorOptions.threshold, + ruleSet: monitorOptions.ruleSet, + }, + onAuditComplete: (result) => { + setResults(result) + }, + }) + + monitor.start() + setIsActive(true) + + return () => { + monitor.stop() + setIsActive(false) + } + }, [ + enabled, + monitorOptions.liveMonitoringDelay, + monitorOptions.threshold, + monitorOptions.ruleSet, + ]) + + return { + results, + isActive, + } +} + +/** + * Hook to subscribe to a11y events + */ +export function useA11yEvents() { + const [lastResults, setLastResults] = useState(null) + const [newIssues, setNewIssues] = useState([]) + const [resolvedIssues, setResolvedIssues] = useState< + A11yAuditResult['issues'] + >([]) + + useEffect(() => { + const cleanupResults = a11yEventClient.on('results', (event) => { + setLastResults(event.payload) + }) + + const cleanupNew = a11yEventClient.on('new-issues', (event) => { + setNewIssues(event.payload.issues) + }) + + const cleanupResolved = a11yEventClient.on('resolved-issues', (event) => { + setResolvedIssues(event.payload.issues) + }) + + return () => { + cleanupResults() + cleanupNew() + cleanupResolved() + } + }, []) + + return { + lastResults, + newIssues, + resolvedIssues, + } +} diff --git a/packages/devtools-a11y/src/react/index.ts b/packages/devtools-a11y/src/react/index.ts new file mode 100644 index 00000000..a7b1e7a4 --- /dev/null +++ b/packages/devtools-a11y/src/react/index.ts @@ -0,0 +1,33 @@ +// React component +export { A11yDevtoolsPanel } from './A11yDevtoolsPanel' + +// Hooks +export { + useA11yAudit, + useA11yRef, + useA11yOverlay, + useA11yLiveMonitor, + useA11yEvents, +} from './hooks' + +// Re-export core types and utilities +export { + createA11yPlugin, + a11yEventClient, + runAudit, + filterByThreshold, + groupIssuesByImpact, + highlightElement, + highlightAllIssues, + clearHighlights, + exportAuditResults, +} from '../index' + +export type { + A11yPluginOptions, + A11yAuditOptions, + A11yAuditResult, + A11yIssue, + SeverityThreshold, + RuleSetPreset, +} from '../types' diff --git a/packages/devtools-a11y/src/scanner/audit.ts b/packages/devtools-a11y/src/scanner/audit.ts new file mode 100644 index 00000000..648c0ad6 --- /dev/null +++ b/packages/devtools-a11y/src/scanner/audit.ts @@ -0,0 +1,359 @@ +import axe from 'axe-core' +import type { AxeResults, RuleObject, RunOptions } from 'axe-core' +import type { + A11yAuditOptions, + A11yAuditResult, + A11yIssue, + A11yNode, + A11ySummary, + GroupedIssues, + RuleSetPreset, + SeverityThreshold, +} from '../types' + +// Re-export RuleObject to suppress unused warning (we use it for type reference) +export type { RuleObject } + +/** + * Severity levels mapped to numeric values for comparison + */ +const IMPACT_SEVERITY: Record = { + critical: 4, + serious: 3, + moderate: 2, + minor: 1, +} + +/** + * Rule set configurations for different presets + */ +const RULE_SET_CONFIGS: Record> = { + wcag2a: { + runOnly: { + type: 'tag', + values: ['wcag2a'], + }, + }, + wcag2aa: { + runOnly: { + type: 'tag', + values: ['wcag2a', 'wcag2aa'], + }, + }, + wcag21aa: { + runOnly: { + type: 'tag', + values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'], + }, + }, + wcag22aa: { + runOnly: { + type: 'tag', + values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'], + }, + }, + section508: { + runOnly: { + type: 'tag', + values: ['section508'], + }, + }, + 'best-practice': { + runOnly: { + type: 'tag', + values: ['best-practice'], + }, + }, + all: { + // Run all rules + }, +} + +/** + * Check if an impact level meets or exceeds the threshold + */ +export function meetsThreshold( + impact: SeverityThreshold | null | undefined, + threshold: SeverityThreshold, +): boolean { + if (!impact) return false + return IMPACT_SEVERITY[impact] >= IMPACT_SEVERITY[threshold] +} + +/** + * Convert axe-core results to our issue format + */ +function convertToIssues( + results: AxeResults, + threshold: SeverityThreshold, +): Array { + const issues: Array = [] + + for (const violation of results.violations) { + const impact = violation.impact as SeverityThreshold | undefined + + for (let i = 0; i < violation.nodes.length; i++) { + const node = violation.nodes[i]! + const selector = node.target.join(', ') + + const a11yNode: A11yNode = { + selector, + html: node.html, + xpath: node.xpath?.join(' > '), + failureSummary: node.failureSummary, + } + + issues.push({ + id: `${violation.id}-${i}-${Date.now()}`, + ruleId: violation.id, + impact: impact || 'minor', + message: node.failureSummary || violation.description, + help: violation.help, + helpUrl: violation.helpUrl, + wcagTags: violation.tags.filter( + (tag) => tag.startsWith('wcag') || tag.startsWith('section508'), + ), + nodes: [a11yNode], + meetsThreshold: meetsThreshold(impact, threshold), + timestamp: Date.now(), + }) + } + } + + return issues +} + +/** + * Create summary statistics from audit results + */ +function createSummary( + results: AxeResults, + issues: Array, +): A11ySummary { + const summary: A11ySummary = { + total: issues.length, + critical: 0, + serious: 0, + moderate: 0, + minor: 0, + passes: results.passes.length, + incomplete: results.incomplete.length, + } + + for (const issue of issues) { + const impact = issue.impact + if (impact === 'critical') summary.critical++ + else if (impact === 'serious') summary.serious++ + else if (impact === 'moderate') summary.moderate++ + else { + summary.minor++ + } + } + + return summary +} + +/** + * Group issues by their impact level + */ +export function groupIssuesByImpact(issues: Array): GroupedIssues { + const grouped: GroupedIssues = { + critical: [], + serious: [], + moderate: [], + minor: [], + } + + for (const issue of issues) { + const impact = issue.impact + if (impact === 'critical') grouped.critical.push(issue) + else if (impact === 'serious') grouped.serious.push(issue) + else if (impact === 'moderate') grouped.moderate.push(issue) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + else if (impact === 'minor') grouped.minor.push(issue) + } + + return grouped +} + +/** + * Filter issues by severity threshold + */ +export function filterByThreshold( + issues: Array, + threshold: SeverityThreshold, +): Array { + return issues.filter((issue) => meetsThreshold(issue.impact, threshold)) +} + +/** + * Get the context description for logging + */ +function getContextDescription(context: Document | Element | string): string { + if (typeof context === 'string') { + return context + } + if (context instanceof Document) { + return 'document' + } + if (context instanceof Element) { + return context.tagName.toLowerCase() + (context.id ? `#${context.id}` : '') + } + return 'unknown' +} + +/** + * Default selectors to exclude from auditing (devtools panels, overlays, etc.) + */ +const DEFAULT_EXCLUDE_SELECTORS = [ + // TanStack Devtools root container + '[data-testid="tanstack_devtools"]', + // A11y overlay elements + '[data-a11y-overlay]', + // Common devtools patterns + '[data-devtools]', + '[data-devtools-panel]', +] + +/** + * Run an accessibility audit using axe-core + */ +export async function runAudit( + options: A11yAuditOptions = {}, +): Promise { + const { + threshold = 'serious', + context = document, + ruleSet = 'wcag21aa', + enabledRules, + disabledRules, + exclude = [], + } = options + + // Merge user exclusions with default devtools exclusions + const allExclusions = [...DEFAULT_EXCLUDE_SELECTORS, ...exclude] + + const startTime = performance.now() + const contextDescription = getContextDescription(context) + + try { + // Build axe-core options + const axeOptions: RunOptions = { + resultTypes: ['violations', 'passes', 'incomplete'], + ...RULE_SET_CONFIGS[ruleSet], + } + + // Handle specific rule configurations + if (enabledRules && enabledRules.length > 0) { + axeOptions.runOnly = { + type: 'rule', + values: enabledRules, + } + } + + // Build rules configuration for disabled rules + if (disabledRules && disabledRules.length > 0) { + const rules: RuleObject = {} + for (const ruleId of disabledRules) { + rules[ruleId] = { enabled: false } + } + axeOptions.rules = rules + } + + // Determine the context to audit + let auditContext: axe.ElementContext = context as axe.ElementContext + + // Add exclusions if specified (always include devtools exclusions) + if (allExclusions.length > 0) { + auditContext = { + include: [auditContext as Element], + exclude: allExclusions.map((sel) => [sel]), + } as axe.ElementContext + } + + // Run the audit + const results = await axe.run(auditContext, axeOptions) + const duration = performance.now() - startTime + + // Convert results to our format + const issues = convertToIssues(results, threshold) + const summary = createSummary(results, issues) + + return { + issues, + summary, + timestamp: Date.now(), + url: typeof window !== 'undefined' ? window.location.href : '', + context: contextDescription, + duration, + } + } catch (error) { + const duration = performance.now() - startTime + console.error('[A11y Audit] Error running axe-core:', error) + + return { + issues: [], + summary: { + total: 0, + critical: 0, + serious: 0, + moderate: 0, + minor: 0, + passes: 0, + incomplete: 0, + }, + timestamp: Date.now(), + url: typeof window !== 'undefined' ? window.location.href : '', + context: contextDescription, + duration, + } + } +} + +/** + * Compare two audit results and find new/resolved issues + */ +export function diffAuditResults( + previous: A11yAuditResult | null, + current: A11yAuditResult, +): { newIssues: Array; resolvedIssues: Array } { + if (!previous) { + return { + newIssues: current.issues, + resolvedIssues: [], + } + } + + // Create sets of issue identifiers for comparison + const previousIds = new Set( + previous.issues.map((i) => `${i.ruleId}:${i.nodes[0]?.selector}`), + ) + const currentIds = new Set( + current.issues.map((i) => `${i.ruleId}:${i.nodes[0]?.selector}`), + ) + + const newIssues = current.issues.filter( + (i) => !previousIds.has(`${i.ruleId}:${i.nodes[0]?.selector}`), + ) + + const resolvedIssues = previous.issues.filter( + (i) => !currentIds.has(`${i.ruleId}:${i.nodes[0]?.selector}`), + ) + + return { newIssues, resolvedIssues } +} + +/** + * Get a list of all available axe-core rules + */ +export function getAvailableRules(): Array<{ + id: string + description: string + tags: Array +}> { + return axe.getRules().map((rule) => ({ + id: rule.ruleId, + description: rule.description, + tags: rule.tags, + })) +} diff --git a/packages/devtools-a11y/src/scanner/index.ts b/packages/devtools-a11y/src/scanner/index.ts new file mode 100644 index 00000000..b1b37ea4 --- /dev/null +++ b/packages/devtools-a11y/src/scanner/index.ts @@ -0,0 +1,11 @@ +export { + runAudit, + groupIssuesByImpact, + filterByThreshold, + meetsThreshold, + diffAuditResults, + getAvailableRules, +} from './audit' + +export { LiveMonitor, getLiveMonitor } from './live-monitor' +export type { LiveMonitorConfig } from './live-monitor' diff --git a/packages/devtools-a11y/src/scanner/live-monitor.ts b/packages/devtools-a11y/src/scanner/live-monitor.ts new file mode 100644 index 00000000..fe3a8864 --- /dev/null +++ b/packages/devtools-a11y/src/scanner/live-monitor.ts @@ -0,0 +1,221 @@ +import { a11yEventClient } from '../event-client' +import { diffAuditResults, runAudit } from './audit' +import type { A11yAuditOptions, A11yAuditResult } from '../types' + +/** + * Configuration for live monitoring + */ +export interface LiveMonitorConfig { + /** Debounce delay in milliseconds (default: 1000) */ + debounceMs?: number + /** Audit options to use for each scan */ + auditOptions?: A11yAuditOptions + /** Callback when new issues are detected */ + onNewIssues?: (result: ReturnType) => void + /** Callback when audit completes */ + onAuditComplete?: (result: A11yAuditResult) => void +} + +/** + * Live monitoring class that watches for DOM changes and triggers re-scans + */ +export class LiveMonitor { + private observer: MutationObserver | null = null + private debounceTimer: ReturnType | null = null + private isRunning = false + private config: Required + private previousResult: A11yAuditResult | null = null + + constructor(config: LiveMonitorConfig = {}) { + this.config = { + debounceMs: config.debounceMs ?? 1000, + auditOptions: config.auditOptions ?? {}, + onNewIssues: config.onNewIssues ?? (() => {}), + onAuditComplete: config.onAuditComplete ?? (() => {}), + } + } + + /** + * Start live monitoring + */ + start(): void { + if (this.observer) { + console.warn('[A11y LiveMonitor] Already running') + return + } + + this.observer = new MutationObserver(this.handleMutations.bind(this)) + + this.observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + attributeFilter: [ + 'class', + 'style', + 'aria-hidden', + 'aria-label', + 'role', + 'tabindex', + ], + }) + + this.isRunning = true + a11yEventClient.emit('live-monitoring', { enabled: true }) + console.log('[A11y LiveMonitor] Started') + + // Run initial scan + this.triggerScan() + } + + /** + * Stop live monitoring + */ + stop(): void { + if (this.observer) { + this.observer.disconnect() + this.observer = null + } + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + + this.isRunning = false + a11yEventClient.emit('live-monitoring', { enabled: false }) + console.log('[A11y LiveMonitor] Stopped') + } + + /** + * Check if monitoring is active + */ + isActive(): boolean { + return this.isRunning + } + + /** + * Update configuration + */ + updateConfig(config: Partial): void { + this.config = { + ...this.config, + ...config, + auditOptions: { + ...this.config.auditOptions, + ...config.auditOptions, + }, + } + } + + /** + * Handle DOM mutations + */ + private handleMutations(mutations: Array): void { + // Filter out irrelevant mutations + const relevantMutations = mutations.filter((mutation) => { + // Ignore mutations in the devtools panel itself + const target = mutation.target as Element + if (target.closest('[data-tanstack-devtools]')) { + return false + } + + // Ignore script and style changes + return !(target.nodeName === 'SCRIPT' || target.nodeName === 'STYLE'); + + + }) + + if (relevantMutations.length === 0) { + return + } + + // Debounce the scan + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + + this.debounceTimer = setTimeout(() => { + this.triggerScan() + }, this.config.debounceMs) + } + + /** + * Trigger an accessibility scan + */ + private async triggerScan(): Promise { + try { + a11yEventClient.emit('scan-start', { + context: this.config.auditOptions.context?.toString() ?? 'document', + }) + + const result = await runAudit(this.config.auditOptions) + + // Calculate diff from previous result + const diff = diffAuditResults(this.previousResult, result) + + // Emit events + a11yEventClient.emit('results', result) + a11yEventClient.emit('scan-complete', { + duration: result.duration, + issueCount: result.issues.length, + }) + + if (diff.newIssues.length > 0) { + a11yEventClient.emit('new-issues', { issues: diff.newIssues }) + } + + if (diff.resolvedIssues.length > 0) { + a11yEventClient.emit('resolved-issues', { issues: diff.resolvedIssues }) + } + + // Call callbacks + this.config.onAuditComplete(result) + if (diff.newIssues.length > 0 || diff.resolvedIssues.length > 0) { + this.config.onNewIssues(diff) + } + + // Store for next comparison + this.previousResult = result + } catch (error) { + console.error('[A11y LiveMonitor] Scan failed:', error) + a11yEventClient.emit('scan-error', { + error: error instanceof Error ? error.message : String(error), + }) + } + } + + /** + * Force an immediate scan (bypass debounce) + */ + async forceScan(): Promise { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + + try { + const result = await runAudit(this.config.auditOptions) + this.previousResult = result + return result + } catch (error) { + console.error('[A11y LiveMonitor] Force scan failed:', error) + return null + } + } +} + +/** + * Create a singleton live monitor instance + */ +let liveMonitorInstance: LiveMonitor | null = null + +export function getLiveMonitor(config?: LiveMonitorConfig): LiveMonitor { + if (!liveMonitorInstance) { + liveMonitorInstance = new LiveMonitor(config) + } else if (config) { + liveMonitorInstance.updateConfig(config) + } + return liveMonitorInstance +} diff --git a/packages/devtools-a11y/src/types.ts b/packages/devtools-a11y/src/types.ts new file mode 100644 index 00000000..2dc26a36 --- /dev/null +++ b/packages/devtools-a11y/src/types.ts @@ -0,0 +1,222 @@ +/** + * Severity threshold for filtering issues + */ +export type SeverityThreshold = 'critical' | 'serious' | 'moderate' | 'minor' + +/** + * WCAG conformance levels + */ +export type WCAGLevel = + | 'wcag2a' + | 'wcag2aa' + | 'wcag2aaa' + | 'wcag21a' + | 'wcag21aa' + | 'wcag21aaa' + | 'wcag22aa' + +/** + * Rule set presets + */ +export type RuleSetPreset = + | 'wcag2a' + | 'wcag2aa' + | 'wcag21aa' + | 'wcag22aa' + | 'section508' + | 'best-practice' + | 'all' + +/** + * Represents a single node affected by an accessibility issue + */ +export interface A11yNode { + /** CSS selector for the element */ + selector: string + /** HTML snippet of the element */ + html: string + /** XPath to the element (optional) */ + xpath?: string + /** Failure summary for this specific node */ + failureSummary?: string +} + +/** + * Represents a single accessibility issue + */ +export interface A11yIssue { + /** Unique identifier for this issue instance */ + id: string + /** The axe-core rule ID */ + ruleId: string + /** Impact severity level */ + impact: SeverityThreshold + /** Human-readable description of the issue */ + message: string + /** Detailed help text */ + help: string + /** URL to learn more about this issue */ + helpUrl: string + /** WCAG tags associated with this rule */ + wcagTags: Array + /** DOM nodes affected by this issue */ + nodes: Array + /** Whether this issue meets the current severity threshold */ + meetsThreshold: boolean + /** Timestamp when this issue was detected */ + timestamp: number +} + +/** + * Grouped issues by impact level + */ +export interface GroupedIssues { + critical: Array + serious: Array + moderate: Array + minor: Array +} + +/** + * Summary statistics for an audit + */ +export interface A11ySummary { + total: number + critical: number + serious: number + moderate: number + minor: number + passes: number + incomplete: number +} + +/** + * Result of an accessibility audit + */ +export interface A11yAuditResult { + /** All issues found */ + issues: Array + /** Summary statistics */ + summary: A11ySummary + /** Timestamp when the audit was run */ + timestamp: number + /** URL of the page audited */ + url: string + /** Description of the context (document, selector, or element) */ + context: string + /** Time taken to run the audit in ms */ + duration: number +} + +/** + * Options for running an audit + */ +export interface A11yAuditOptions { + /** Minimum severity to report (default: 'serious') */ + threshold?: SeverityThreshold + /** DOM context to audit (default: document) */ + context?: Document | Element | string + /** Rule set preset to use (default: 'wcag21aa') */ + ruleSet?: RuleSetPreset + /** Specific rules to enable (overrides ruleSet) */ + enabledRules?: Array + /** Specific rules to disable */ + disabledRules?: Array + /** Selectors to exclude from auditing */ + exclude?: Array +} + +/** + * Options for the A11y plugin + */ +export interface A11yPluginOptions { + /** Minimum severity threshold (default: 'serious') */ + threshold?: SeverityThreshold + /** Run audit automatically on mount (default: false) */ + runOnMount?: boolean + /** Enable live monitoring with MutationObserver (default: false) */ + liveMonitoring?: boolean + /** Debounce delay for live monitoring in ms (default: 1000) */ + liveMonitoringDelay?: number + /** Rule set preset (default: 'wcag21aa') */ + ruleSet?: RuleSetPreset + /** Show visual overlays on page (default: true) */ + showOverlays?: boolean + /** Persist settings to localStorage (default: true) */ + persistSettings?: boolean +} + +/** + * State of the A11y plugin + */ +export interface A11yPluginState { + /** Whether an audit is currently running */ + isScanning: boolean + /** Latest audit results */ + results: A11yAuditResult | null + /** Previous audit results (for diff detection) */ + previousResults: A11yAuditResult | null + /** Current configuration */ + config: Required + /** Currently selected issue ID */ + selectedIssueId: string | null + /** Whether overlays are visible */ + overlaysVisible: boolean + /** Whether live monitoring is active */ + isLiveMonitoring: boolean + /** Error message if any */ + error: string | null +} + +/** + * Plugin ID constant + */ +export const A11Y_PLUGIN_ID = 'a11y' as const + +/** + * Event payloads for the event client. + * Keys must follow the pattern `{pluginId}:{eventSuffix}` + */ +export interface A11yEventMap { + /** Emitted when audit results are available */ + 'a11y:results': A11yAuditResult + /** Emitted when an audit starts */ + 'a11y:scan-start': { context: string } + /** Emitted when an audit completes */ + 'a11y:scan-complete': { duration: number; issueCount: number } + /** Emitted when an audit fails */ + 'a11y:scan-error': { error: string } + /** Request to highlight an element */ + 'a11y:highlight': { selector: string; impact: SeverityThreshold } + /** Request to clear all highlights */ + 'a11y:clear-highlights': Record + /** Request to highlight all issues */ + 'a11y:highlight-all': { issues: Array } + /** Configuration changed */ + 'a11y:config-change': Partial + /** Live monitoring status changed */ + 'a11y:live-monitoring': { enabled: boolean } + /** New issues detected (diff from previous scan) */ + 'a11y:new-issues': { issues: Array } + /** Issues resolved (diff from previous scan) */ + 'a11y:resolved-issues': { issues: Array } +} + +/** + * Export format options + */ +export type ExportFormat = 'json' | 'csv' + +/** + * Export options + */ +export interface ExportOptions { + /** Export format */ + format: ExportFormat + /** Include passing rules in export */ + includePasses?: boolean + /** Include incomplete rules in export */ + includeIncomplete?: boolean + /** Custom filename (without extension) */ + filename?: string +} diff --git a/packages/devtools-a11y/tests/config.test.ts b/packages/devtools-a11y/tests/config.test.ts new file mode 100644 index 00000000..968ee0ed --- /dev/null +++ b/packages/devtools-a11y/tests/config.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DEFAULT_CONFIG, + clearConfig, + loadConfig, + mergeConfig, + saveConfig, +} from '../src' + +describe('config', () => { + // Mock localStorage + const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + removeItem: vi.fn((key: string) => { + delete store[key] + }), + clear: vi.fn(() => { + store = {} + }), + } + })() + + beforeEach(() => { + vi.stubGlobal('localStorage', localStorageMock) + localStorageMock.clear() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('DEFAULT_CONFIG', () => { + it('should have expected default values', () => { + expect(DEFAULT_CONFIG.threshold).toBe('serious') + expect(DEFAULT_CONFIG.runOnMount).toBe(false) + expect(DEFAULT_CONFIG.liveMonitoring).toBe(false) + expect(DEFAULT_CONFIG.liveMonitoringDelay).toBe(1000) + expect(DEFAULT_CONFIG.ruleSet).toBe('wcag21aa') + expect(DEFAULT_CONFIG.showOverlays).toBe(true) + expect(DEFAULT_CONFIG.persistSettings).toBe(true) + }) + }) + + describe('loadConfig', () => { + it('should return DEFAULT_CONFIG when localStorage is empty', () => { + const config = loadConfig() + expect(config).toEqual(DEFAULT_CONFIG) + }) + + it('should merge stored config with defaults', () => { + localStorageMock.setItem( + 'tanstack-devtools-a11y-config', + JSON.stringify({ ruleSet: 'wcag22aa', liveMonitoring: true }), + ) + + const config = loadConfig() + expect(config.ruleSet).toBe('wcag22aa') + expect(config.liveMonitoring).toBe(true) + expect(config.threshold).toBe('serious') // default preserved + }) + + it('should return DEFAULT_CONFIG when stored JSON is invalid', () => { + localStorageMock.setItem( + 'tanstack-devtools-a11y-config', + 'invalid json{{{', + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = loadConfig() + expect(config).toEqual(DEFAULT_CONFIG) + expect(warnSpy).toHaveBeenCalled() + }) + }) + + describe('saveConfig', () => { + it('should save config to localStorage', () => { + saveConfig({ ruleSet: 'section508' }) + + expect(localStorageMock.setItem).toHaveBeenCalled() + const stored = JSON.parse( + localStorageMock.getItem('tanstack-devtools-a11y-config') || '{}', + ) + expect(stored.ruleSet).toBe('section508') + }) + + it('should merge with existing config', () => { + localStorageMock.setItem( + 'tanstack-devtools-a11y-config', + JSON.stringify({ liveMonitoring: true }), + ) + + saveConfig({ ruleSet: 'wcag22aa' }) + + const stored = JSON.parse( + localStorageMock.getItem('tanstack-devtools-a11y-config') || '{}', + ) + expect(stored.ruleSet).toBe('wcag22aa') + expect(stored.liveMonitoring).toBe(true) + }) + }) + + describe('clearConfig', () => { + it('should remove config from localStorage', () => { + localStorageMock.setItem( + 'tanstack-devtools-a11y-config', + JSON.stringify({ ruleSet: 'wcag22aa' }), + ) + + clearConfig() + + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + 'tanstack-devtools-a11y-config', + ) + }) + }) + + describe('mergeConfig', () => { + it('should merge user options with saved config when persistSettings is true', () => { + localStorageMock.setItem( + 'tanstack-devtools-a11y-config', + JSON.stringify({ liveMonitoring: true }), + ) + + const config = mergeConfig({ ruleSet: 'wcag22aa' }) + expect(config.ruleSet).toBe('wcag22aa') + expect(config.liveMonitoring).toBe(true) + }) + + it('should ignore saved config when persistSettings is false', () => { + localStorageMock.setItem( + 'tanstack-devtools-a11y-config', + JSON.stringify({ liveMonitoring: true }), + ) + + const config = mergeConfig({ persistSettings: false }) + expect(config.liveMonitoring).toBe(false) // default, not saved + }) + + it('should return defaults when no options provided', () => { + const config = mergeConfig() + expect(config).toEqual(DEFAULT_CONFIG) + }) + }) +}) diff --git a/packages/devtools-a11y/tests/export.test.ts b/packages/devtools-a11y/tests/export.test.ts new file mode 100644 index 00000000..d4653810 --- /dev/null +++ b/packages/devtools-a11y/tests/export.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import { + exportToCsv, + exportToJson, + generateSummaryReport, +} from '../src' +import type { A11yAuditResult } from '../src' + +const createMockResult = (): A11yAuditResult => ({ + issues: [ + { + id: 'issue-1', + ruleId: 'image-alt', + impact: 'critical', + message: 'Images must have alternate text', + help: 'Images must have alternate text', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.0/image-alt', + wcagTags: ['wcag2a', 'wcag111'], + nodes: [ + { + selector: 'img.logo', + html: '', + failureSummary: 'Fix this issue by adding an alt attribute', + }, + ], + meetsThreshold: true, + timestamp: 1704067200000, + }, + { + id: 'issue-2', + ruleId: 'button-name', + impact: 'serious', + message: 'Buttons must have discernible text', + help: 'Buttons must have discernible text', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.0/button-name', + wcagTags: ['wcag2a', 'wcag412'], + nodes: [ + { + selector: 'button.submit', + html: '', + failureSummary: 'Add text or aria-label to the button', + }, + { + selector: 'button.cancel', + html: '', + failureSummary: 'Add text or aria-label to the button', + }, + ], + meetsThreshold: true, + timestamp: 1704067200000, + }, + ], + summary: { + total: 2, + critical: 1, + serious: 1, + moderate: 0, + minor: 0, + passes: 50, + incomplete: 3, + }, + timestamp: 1704067200000, + url: 'http://localhost:3000/', + context: 'document', + duration: 123.45, +}) + +describe('export', () => { + describe('exportToJson', () => { + it('should export audit result to JSON format', () => { + const result = createMockResult() + const json = exportToJson(result) + + const parsed = JSON.parse(json) + expect(parsed.meta).toBeDefined() + expect(parsed.meta.url).toBe('http://localhost:3000/') + expect(parsed.meta.context).toBe('document') + expect(parsed.summary).toBeDefined() + expect(parsed.summary.total).toBe(2) + expect(parsed.issues).toHaveLength(2) + }) + + it('should include all issue details', () => { + const result = createMockResult() + const json = exportToJson(result) + + const parsed = JSON.parse(json) + const firstIssue = parsed.issues[0] + + expect(firstIssue.ruleId).toBe('image-alt') + expect(firstIssue.impact).toBe('critical') + expect(firstIssue.helpUrl).toContain('dequeuniversity.com') + expect(firstIssue.nodes).toHaveLength(1) + }) + + it('should include node details', () => { + const result = createMockResult() + const json = exportToJson(result) + + const parsed = JSON.parse(json) + const node = parsed.issues[0].nodes[0] + + expect(node.selector).toBe('img.logo') + expect(node.html).toContain(' { + it('should export audit result to CSV format', () => { + const result = createMockResult() + const csv = exportToCsv(result) + + expect(csv).toContain('Rule ID') + expect(csv).toContain('Impact') + expect(csv).toContain('Message') + expect(csv).toContain('Help URL') + expect(csv).toContain('WCAG Tags') + expect(csv).toContain('Selector') + expect(csv).toContain('HTML') + }) + + it('should include one row per affected node', () => { + const result = createMockResult() + const csv = exportToCsv(result) + + const lines = csv.split('\n') + // Header + 3 nodes (1 from issue 1 + 2 from issue 2) + expect(lines).toHaveLength(4) + }) + + it('should escape quotes in content', () => { + const result = createMockResult() + const firstIssue = result.issues[0] + if (firstIssue) { + firstIssue.message = 'Message with "quotes" inside' + } + const csv = exportToCsv(result) + + expect(csv).toContain('""quotes""') + }) + + it('should join WCAG tags with semicolons', () => { + const result = createMockResult() + const csv = exportToCsv(result) + + expect(csv).toContain('wcag2a; wcag111') + }) + }) + + describe('generateSummaryReport', () => { + it('should generate a human-readable summary', () => { + const result = createMockResult() + const report = generateSummaryReport(result) + + expect(report).toContain('ACCESSIBILITY AUDIT REPORT') + expect(report).toContain('URL: http://localhost:3000/') + expect(report).toContain('Total Issues: 2') + expect(report).toContain('Critical: 1') + expect(report).toContain('Serious: 1') + expect(report).toContain('Passing Rules: 50') + }) + + it('should group issues by impact', () => { + const result = createMockResult() + const report = generateSummaryReport(result) + + expect(report).toContain('[CRITICAL]') + expect(report).toContain('[SERIOUS]') + expect(report).toContain('image-alt') + expect(report).toContain('button-name') + }) + + it('should include selector and help URL', () => { + const result = createMockResult() + const report = generateSummaryReport(result) + + expect(report).toContain('Selector: img.logo') + expect(report).toContain('Learn more: https://dequeuniversity.com') + }) + + it('should handle result with no issues', () => { + const result: A11yAuditResult = { + issues: [], + summary: { + total: 0, + critical: 0, + serious: 0, + moderate: 0, + minor: 0, + passes: 50, + incomplete: 0, + }, + timestamp: 1704067200000, + url: 'http://localhost:3000/', + context: 'document', + duration: 50, + } + + const report = generateSummaryReport(result) + + expect(report).toContain('Total Issues: 0') + expect(report).not.toContain('[CRITICAL]') + expect(report).not.toContain('[SERIOUS]') + }) + }) +}) diff --git a/packages/devtools-a11y/tsconfig.json b/packages/devtools-a11y/tsconfig.json new file mode 100644 index 00000000..2330aa2d --- /dev/null +++ b/packages/devtools-a11y/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["src", "eslint.config.js", "vite.config.ts", "tests"] +} diff --git a/packages/devtools-a11y/vite.config.ts b/packages/devtools-a11y/vite.config.ts new file mode 100644 index 00000000..1a88b79f --- /dev/null +++ b/packages/devtools-a11y/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import react from '@vitejs/plugin-react' +import packageJson from './package.json' +import type { Plugin } from 'vitest/config' + +const config = defineConfig({ + plugins: [react() as unknown as Plugin], + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + globals: true, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/react/index.ts'], + srcDir: './src', + cjs: true, + }), +) diff --git a/packages/devtools/src/tabs/plugin-registry.ts b/packages/devtools/src/tabs/plugin-registry.ts index ab656be3..c87c4fb1 100644 --- a/packages/devtools/src/tabs/plugin-registry.ts +++ b/packages/devtools/src/tabs/plugin-registry.ts @@ -213,6 +213,24 @@ const PLUGIN_REGISTRY: Record = { // ========================================== // External contributors can add their plugins below! + // TanStack A11y Devtools + '@tanstack/devtools-a11y': { + packageName: '@tanstack/devtools-a11y', + title: 'Accessibility Devtools', + description: + 'Audit accessibility issues in real-time with axe-core. Supports WCAG 2.1/2.2, live monitoring, and visual overlays.', + pluginImport: { + importName: 'createA11yPlugin', + type: 'function', + }, + pluginId: 'devtools-a11y', + docsUrl: 'https://tanstack.com/devtools/latest/docs/plugins/a11y', + author: 'TanStack', + framework: 'react', + isNew: true, + tags: ['accessibility', 'a11y', 'wcag', 'axe-core', 'audit'], + }, + // Dimano — Prefetch Heatmap for TanStack Router '@dimano/ts-devtools-plugin-prefetch-heatmap': { packageName: '@dimano/ts-devtools-plugin-prefetch-heatmap', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d13b7ee2..ab0cfe1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,34 @@ importers: specifier: ^7.1.7 version: 7.2.6(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) + examples/react/a11y-devtools: + dependencies: + '@tanstack/devtools-a11y': + specifier: workspace:* + version: link:../../../packages/devtools-a11y + '@tanstack/react-devtools': + specifier: workspace:* + version: link:../../../packages/react-devtools + react: + specifier: ^19.2.0 + version: 19.2.3 + react-dom: + specifier: ^19.2.0 + version: 19.2.3(react@19.2.3) + devDependencies: + '@types/react': + specifier: ^19.2.0 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) + vite: + specifier: ^7.1.7 + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) + examples/react/basic: dependencies: '@tanstack/devtools-client': @@ -586,6 +614,25 @@ importers: specifier: ^2.11.8 version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) + packages/devtools-a11y: + dependencies: + '@tanstack/devtools-event-client': + specifier: workspace:* + version: link:../event-bus-client + axe-core: + specifier: ^4.10.0 + version: 4.11.1 + devDependencies: + '@types/react': + specifier: ^19.2.0 + version: 19.2.7 + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) + react: + specifier: ^19.2.0 + version: 19.2.3 + packages/devtools-client: dependencies: '@tanstack/devtools-event-client': @@ -3944,6 +3991,10 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + axios@1.13.1: resolution: {integrity: sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==} @@ -4373,6 +4424,7 @@ packages: dax-sh@0.43.2: resolution: {integrity: sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg==} + deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' dayjs@1.11.18: resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} @@ -8216,6 +8268,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -11932,6 +11985,8 @@ snapshots: aws-ssl-profiles@1.1.2: optional: true + axe-core@4.11.1: {} + axios@1.13.1(debug@4.4.3): dependencies: follow-redirects: 1.15.9(debug@4.4.3) From 6bf0698baac15cdcd490c90d49602591edae44bf Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 13 Jan 2026 10:02:28 -0800 Subject: [PATCH 02/16] wip: enhance accessibility panel with improved issue handling and UI updates --- .../devtools-a11y/src/overlay/highlight.ts | 2 +- packages/devtools-a11y/src/plugin.ts | 26 +- .../src/react/A11yDevtoolsPanel.tsx | 373 +++++++++++------- 3 files changed, 236 insertions(+), 165 deletions(-) diff --git a/packages/devtools-a11y/src/overlay/highlight.ts b/packages/devtools-a11y/src/overlay/highlight.ts index 5ccee39f..cdf3b154 100644 --- a/packages/devtools-a11y/src/overlay/highlight.ts +++ b/packages/devtools-a11y/src/overlay/highlight.ts @@ -247,7 +247,7 @@ export function highlightElement( // Scroll first highlighted element into view if (highlightedCount === 0) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) } highlightedCount++ diff --git a/packages/devtools-a11y/src/plugin.ts b/packages/devtools-a11y/src/plugin.ts index ab5a7e0a..16b91455 100644 --- a/packages/devtools-a11y/src/plugin.ts +++ b/packages/devtools-a11y/src/plugin.ts @@ -438,9 +438,7 @@ export function createA11yPlugin( function attachEventHandlers() { // Scan button - const scanBtn = el.querySelector( - '#scan-btn', - ) + const scanBtn = el.querySelector('#scan-btn') if (scanBtn) { scanBtn.onclick = async () => { scanBtn.textContent = 'Scanning...' @@ -475,9 +473,7 @@ export function createA11yPlugin( } // Export button - const exportBtn = el.querySelector( - '#export-btn', - ) + const exportBtn = el.querySelector('#export-btn') if (exportBtn && currentResults) { exportBtn.onclick = () => { exportAuditResults(currentResults!, { format: 'json' }) @@ -485,7 +481,7 @@ export function createA11yPlugin( } // Toggle overlay button - const toggleOverlayBtn = el.querySelector( + const toggleOverlayBtn = el.querySelector( '#toggle-overlay-btn', ) if (toggleOverlayBtn) { @@ -508,9 +504,8 @@ export function createA11yPlugin( } // Threshold select - const thresholdSelect = el.querySelector( - '#threshold-select', - ) + const thresholdSelect = + el.querySelector('#threshold-select') if (thresholdSelect) { thresholdSelect.onchange = () => { config.threshold = thresholdSelect.value as SeverityThreshold @@ -523,9 +518,8 @@ export function createA11yPlugin( } // Rule set select - const rulesetSelect = el.querySelector( - '#ruleset-select', - ) + const rulesetSelect = + el.querySelector('#ruleset-select') if (rulesetSelect) { rulesetSelect.onchange = () => { config.ruleSet = rulesetSelect.value as RuleSetPreset @@ -535,7 +529,7 @@ export function createA11yPlugin( } // Live monitoring checkbox - const liveMonitorCheckbox = el.querySelector( + const liveMonitorCheckbox = el.querySelector( '#live-monitor-checkbox', ) if (liveMonitorCheckbox) { @@ -591,9 +585,7 @@ export function createA11yPlugin( // Run on mount if configured if (config.runOnMount) { - const scanBtn = el.querySelector( - '#scan-btn', - ) + const scanBtn = el.querySelector('#scan-btn') if (scanBtn) { scanBtn.click() } diff --git a/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx b/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx index 6ff2dee0..bec946f5 100644 --- a/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx +++ b/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx @@ -1,11 +1,26 @@ import { useEffect, useState } from 'react' import { a11yEventClient } from '../event-client' -import { filterByThreshold, getLiveMonitor, groupIssuesByImpact, runAudit, } from '../scanner' -import { clearHighlights, highlightAllIssues, highlightElement, initOverlayAdapter, } from '../overlay' +import { + filterByThreshold, + getLiveMonitor, + groupIssuesByImpact, + runAudit, +} from '../scanner' +import { + clearHighlights, + highlightAllIssues, + highlightElement, + initOverlayAdapter, +} from '../overlay' import { mergeConfig, saveConfig } from '../config' import { exportAuditResults } from '../export' import type { JSX } from 'react' -import type { A11yAuditResult, A11yPluginOptions, RuleSetPreset, SeverityThreshold, } from '../types' +import type { + A11yAuditResult, + A11yPluginOptions, + RuleSetPreset, + SeverityThreshold, +} from '../types' const SEVERITY_COLORS = { critical: '#dc2626', @@ -34,11 +49,11 @@ function scrollToElement(selector: string): boolean { try { const element = document.querySelector(selector) if (element) { - // Scroll the element into view + // Scroll the element into view at the top so it's visible above the devtools panel element.scrollIntoView({ behavior: 'smooth', - block: 'center', - inline: 'center', + block: 'start', + inline: 'nearest', }) return true } @@ -59,6 +74,9 @@ export function A11yDevtoolsPanel({ const [results, setResults] = useState(null) const [isScanning, setIsScanning] = useState(false) const [selectedIssueId, setSelectedIssueId] = useState(null) + const [selectedSeverity, setSelectedSeverity] = useState< + 'all' | SeverityThreshold + >('all') const isDark = theme === 'dark' const bg = isDark ? '#1a1a2e' : '#ffffff' @@ -164,22 +182,43 @@ export function A11yDevtoolsPanel({ } } - const handleIssueClick = (issueId: string, selector: string) => { + const handleIssueClick = (issueId: string) => { setSelectedIssueId(issueId) clearHighlights() const issue = results?.issues.find((i) => i.id === issueId) - if (issue) { - // Scroll to the element first - scrollToElement(selector) + if (issue && issue.nodes.length > 0) { + // Try each node's selector until we find one that exists in the DOM + let scrolled = false + for (const node of issue.nodes) { + const selector = node.selector + if (!selector) continue + + try { + const element = document.querySelector(selector) + if (element) { + // Scroll to the first matching element + if (!scrolled) { + scrollToElement(selector) + scrolled = true + } - // Highlight the element - highlightElement(selector, issue.impact) + // Highlight the element + highlightElement(selector, issue.impact) + } + } catch (error) { + // Invalid selector, skip + console.warn('[A11y Panel] Invalid selector:', selector, error) + } + } - // Emit event for other listeners - a11yEventClient.emit('highlight', { - selector, - impact: issue.impact, - }) + // Emit event for other listeners (use first selector) + const primarySelector = issue.nodes[0]?.selector + if (primarySelector) { + a11yEventClient.emit('highlight', { + selector: primarySelector, + impact: issue.impact, + }) + } } } @@ -222,8 +261,8 @@ export function A11yDevtoolsPanel({ color: isDark ? '#94a3b8' : '#64748b', }} > - {results.summary.total} issue - {results.summary.total !== 1 ? 's' : ''} + {filteredIssues.length} issue + {filteredIssues.length !== 1 ? 's' : ''} )} @@ -425,44 +464,66 @@ export function A11yDevtoolsPanel({ }} > {(['critical', 'serious', 'moderate', 'minor'] as const).map( - (impact) => ( -
-
- {results.summary[impact]} -
-
{ + const count = grouped[impact].length + const isActive = selectedSeverity === impact + return ( +
-
- ), +
+ {count} +
+
+ {SEVERITY_LABELS[impact]} +
+ + ) + }, )} {/* Issue List */} {(['critical', 'serious', 'moderate', 'minor'] as const).map( (impact) => { + // If a specific severity is selected, only show that section + if (selectedSeverity !== 'all' && selectedSeverity !== impact) + return null + const issues = grouped[impact] - if (issues.length === 0) return null + + // If 'all' is selected, show only sections that have issues + if (selectedSeverity === 'all' && issues.length === 0) + return null return (
@@ -478,122 +539,140 @@ export function A11yDevtoolsPanel({ > {SEVERITY_LABELS[impact]} ({issues.length}) - {issues.map((issue) => { - const selector = issue.nodes[0]?.selector || 'unknown' - const isSelected = selectedIssueId === issue.id - - return ( -
handleIssueClick(issue.id, selector)} - style={{ - padding: '12px', - marginBottom: '8px', - background: isSelected - ? isDark - ? '#1e3a5f' - : '#e0f2fe' - : secondaryBg, - border: `1px solid ${isSelected ? '#0ea5e9' : borderColor}`, - borderRadius: '6px', - cursor: 'pointer', - }} - > + + {issues.length === 0 ? ( +
+ No issues of this severity +
+ ) : ( + issues.map((issue) => { + const selector = issue.nodes[0]?.selector || 'unknown' + const isSelected = selectedIssueId === issue.id + + return (
handleIssueClick(issue.id)} style={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', + padding: '12px', + marginBottom: '8px', + background: isSelected + ? isDark + ? '#1e3a5f' + : '#e0f2fe' + : secondaryBg, + border: `1px solid ${isSelected ? '#0ea5e9' : borderColor}`, + borderRadius: '6px', + cursor: 'pointer', }} > -
-
- +
+
- {issue.ruleId} + > + + {issue.ruleId} +
+

+ {issue.message} +

+
+ {selector} +
-

e.stopPropagation()} style={{ - fontSize: '12px', - color: isDark ? '#cbd5e1' : '#475569', - margin: '0 0 8px 0', - lineHeight: 1.4, + fontSize: '11px', + color: '#0ea5e9', + textDecoration: 'none', + flexShrink: 0, + marginLeft: '12px', }} > - {issue.message} -

+ Learn more → + +
+ {issue.wcagTags.length > 0 && (
- {selector} + {issue.wcagTags.slice(0, 3).map((tag) => ( + + {tag} + + ))}
-
- e.stopPropagation()} - style={{ - fontSize: '11px', - color: '#0ea5e9', - textDecoration: 'none', - flexShrink: 0, - marginLeft: '12px', - }} - > - Learn more → - + )}
- {issue.wcagTags.length > 0 && ( -
- {issue.wcagTags.slice(0, 3).map((tag) => ( - - {tag} - - ))} -
- )} -
- ) - })} + ) + }) + )}
) }, From e52240c47811474f9792c966feaa9ed242154b98 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 13 Jan 2026 10:52:27 -0800 Subject: [PATCH 03/16] wip: refine issue highlighting to respect threshold settings in accessibility panel --- .../src/react/A11yDevtoolsPanel.tsx | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx b/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx index bec946f5..45b82a42 100644 --- a/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx +++ b/packages/devtools-a11y/src/react/A11yDevtoolsPanel.tsx @@ -114,8 +114,13 @@ export function A11yDevtoolsPanel({ issueCount: result.issues.length, }) - if (config.showOverlays && result.issues.length > 0) { - highlightAllIssues(result.issues) + // Highlight only issues that meet the threshold + const issuesAboveThreshold = filterByThreshold( + result.issues, + config.threshold, + ) + if (config.showOverlays && issuesAboveThreshold.length > 0) { + highlightAllIssues(issuesAboveThreshold) } } catch (error) { console.error('[A11y Panel] Scan failed:', error) @@ -138,8 +143,15 @@ export function A11yDevtoolsPanel({ setConfig((prev) => ({ ...prev, showOverlays: newValue })) saveConfig({ showOverlays: newValue }) - if (newValue && results && results.issues.length > 0) { - highlightAllIssues(results.issues) + if (newValue && results) { + // Highlight only issues that meet the threshold + const issuesAboveThreshold = filterByThreshold( + results.issues, + config.threshold, + ) + if (issuesAboveThreshold.length > 0) { + highlightAllIssues(issuesAboveThreshold) + } } else { clearHighlights() } @@ -149,6 +161,15 @@ export function A11yDevtoolsPanel({ setConfig((prev) => ({ ...prev, threshold })) saveConfig({ threshold }) a11yEventClient.emit('config-change', { threshold }) + + // Re-highlight with new threshold if overlays are enabled + if (config.showOverlays && results) { + clearHighlights() + const issuesAboveThreshold = filterByThreshold(results.issues, threshold) + if (issuesAboveThreshold.length > 0) { + highlightAllIssues(issuesAboveThreshold) + } + } } const handleRuleSetChange = (ruleSet: RuleSetPreset) => { @@ -169,8 +190,13 @@ export function A11yDevtoolsPanel({ }, onAuditComplete: (result) => { setResults(result) - if (config.showOverlays && result.issues.length > 0) { - highlightAllIssues(result.issues) + // Highlight only issues that meet the threshold + const issuesAboveThreshold = filterByThreshold( + result.issues, + config.threshold, + ) + if (config.showOverlays && issuesAboveThreshold.length > 0) { + highlightAllIssues(issuesAboveThreshold) } }, }) @@ -330,6 +356,7 @@ export function A11yDevtoolsPanel({ alignItems: 'center', flexShrink: 0, fontSize: '13px', + flexWrap: 'wrap', }} >