Skip to content

Commit b15c9f7

Browse files
committed
wip: implement live monitoring feature with customizable delay and enhance configuration options
1 parent faa6128 commit b15c9f7

File tree

13 files changed

+640
-319
lines changed

13 files changed

+640
-319
lines changed

docs/plugins/a11y.md

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -81,44 +81,42 @@ This makes it easy to locate and inspect issues directly on the page.
8181

8282
## Panel Configuration
8383

84-
The `A11yDevtoolsPanel` component accepts the following props:
84+
`A11yDevtoolsPanel` is React-first and takes an `options` prop that matches `A11yPluginOptions`.
8585

8686
```tsx
87+
import type { A11yPluginOptions } from '@tanstack/devtools-a11y'
88+
8789
interface A11yDevtoolsPanelProps {
88-
/** Default WCAG standard to use */
89-
defaultStandard?: 'wcag2a' | 'wcag2aa' | 'wcag2aaa' | 'wcag21a' | 'wcag21aa' | 'wcag21aaa' | 'wcag22aa' | 'section508' | 'best-practice'
90-
91-
/** Enable live monitoring by default */
92-
defaultLiveMonitoring?: boolean
93-
94-
/** Show overlays by default */
95-
defaultShowOverlays?: boolean
96-
97-
/** Enable scoped scanning by default */
98-
defaultScopedMode?: boolean
99-
100-
/** Auto-scan on panel mount */
101-
autoScan?: boolean
102-
103-
/** Custom axe-core rules to include */
104-
includeRules?: string[]
105-
106-
/** Custom axe-core rules to exclude */
107-
excludeRules?: string[]
108-
109-
/** CSS selectors to exclude from scanning */
110-
excludeSelectors?: string[]
90+
options?: A11yPluginOptions
91+
theme?: 'light' | 'dark'
11192
}
11293
```
11394

95+
Common `options` fields:
96+
97+
- `threshold`: minimum impact level to show
98+
- `ruleSet`: rule preset (`'wcag2a' | 'wcag2aa' | 'wcag21aa' | 'wcag22aa' | 'section508' | 'best-practice' | 'all'`)
99+
- `disabledRules`: rule IDs to ignore
100+
- `showOverlays`: highlight issues in the page
101+
- `runOnMount`: auto-scan when panel mounts
102+
- `persistSettings`: store config in localStorage
103+
- `liveMonitoring`: watch DOM mutations and re-scan automatically
104+
- `liveMonitoringDelay`: debounce delay (ms)
105+
114106
### Example with Configuration
115107

116108
```tsx
117109
<A11yDevtoolsPanel
118-
defaultStandard="wcag21aa"
119-
defaultLiveMonitoring={true}
120-
autoScan={true}
121-
excludeSelectors={['.third-party-widget', '[data-testid="skip-a11y"]']}
110+
theme={theme}
111+
options={{
112+
ruleSet: 'wcag21aa',
113+
threshold: 'moderate',
114+
runOnMount: true,
115+
showOverlays: true,
116+
liveMonitoring: true,
117+
liveMonitoringDelay: 1000,
118+
disabledRules: ['color-contrast'],
119+
}}
122120
/>
123121
```
124122

@@ -201,33 +199,32 @@ function MyComponent() {
201199

202200
## Vanilla JavaScript API
203201

204-
For non-React applications, use the vanilla JavaScript plugin:
202+
For non-React applications, `createA11yPlugin()` exposes a small programmatic API in addition to the TanStack Devtools `render()` integration:
205203

206204
```ts
207205
import { createA11yPlugin } from '@tanstack/devtools-a11y'
208206

209207
const plugin = createA11yPlugin({
210-
standard: 'wcag21aa',
208+
ruleSet: 'wcag21aa',
211209
liveMonitoring: true,
212210
showOverlays: true,
213211
})
214212

215213
// Run a scan
216-
const result = await plugin.scan()
214+
const result = await plugin.scan?.()
217215

218-
// Start live monitoring
219-
plugin.startLiveMonitoring()
220-
221-
// Stop live monitoring
222-
plugin.stopLiveMonitoring()
223-
224-
// Subscribe to scan results
225-
plugin.onScan((result) => {
226-
console.log('Issues found:', result.issues.length)
216+
// Subscribe to scan results (manual or live)
217+
const unsubscribe = plugin.onScan?.((next) => {
218+
console.log('Issues found:', next.issues.length)
227219
})
228220

221+
// Control live monitoring
222+
plugin.startLiveMonitoring?.()
223+
plugin.stopLiveMonitoring?.()
224+
229225
// Clean up
230-
plugin.destroy()
226+
unsubscribe?.()
227+
plugin.destroy?.()
231228
```
232229

233230
## Export Formats

examples/react/basic/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"zod": "^4.1.11"
2323
},
2424
"devDependencies": {
25-
"@tanstack/devtools-a11y": "workspace:^",
2625
"@tanstack/devtools-ui": "0.4.4",
2726
"@tanstack/devtools-vite": "0.4.1",
2827
"@tanstack/react-form-devtools": "^0.1.7",

examples/react/basic/vite.config.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import path from 'node:path'
21
import { defineConfig } from 'vite'
32
import react from '@vitejs/plugin-react'
43
import { devtools } from '@tanstack/devtools-vite'
@@ -20,18 +19,6 @@ export default defineConfig({
2019
// },
2120
}),
2221
],
23-
resolve: {
24-
alias: {
25-
'@tanstack/devtools-a11y/react': path.resolve(
26-
__dirname,
27-
'../../../packages/devtools-a11y/src/react',
28-
),
29-
'@tanstack/devtools-a11y': path.resolve(
30-
__dirname,
31-
'../../../packages/devtools-a11y/src',
32-
),
33-
},
34-
},
3522
build: {
3623
sourcemap: true,
3724
},

packages/devtools-a11y/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tanstack/devtools-a11y",
3-
"version": "0.0.1",
3+
"version": "0.1.0",
44
"description": "Accessibility auditing plugin for TanStack Devtools powered by axe-core",
55
"author": "TanStack",
66
"license": "MIT",

packages/devtools-a11y/src/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ const STORAGE_KEY = 'tanstack-devtools-a11y-config'
88
export const DEFAULT_CONFIG: Required<A11yPluginOptions> = {
99
threshold: 'serious',
1010
runOnMount: false,
11+
liveMonitoring: false,
12+
liveMonitoringDelay: 1000,
1113
ruleSet: 'wcag21aa',
1214
showOverlays: true,
1315
persistSettings: true,
1416
engine: 'axe-core',
1517
disabledRules: [],
16-
rootSelector: '',
1718
}
1819

1920
/**
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { diffAuditResults, filterByThreshold, runAudit } from './scanner'
2+
import type {
3+
A11yAuditOptions,
4+
A11yAuditResult,
5+
A11yIssue,
6+
SeverityThreshold,
7+
} from './types'
8+
9+
export interface LiveMonitoringOptions {
10+
delay: number
11+
threshold: SeverityThreshold
12+
disabledRules: Array<string>
13+
context?: A11yAuditOptions['context']
14+
}
15+
16+
export interface LiveMonitoringCallbacks {
17+
onResults: (result: A11yAuditResult) => void
18+
onDiff?: (diff: {
19+
newIssues: Array<A11yIssue>
20+
resolvedIssues: Array<A11yIssue>
21+
}) => void
22+
}
23+
24+
export function createLiveMonitoringController(
25+
opts: LiveMonitoringOptions,
26+
cb: LiveMonitoringCallbacks,
27+
): {
28+
start: () => void
29+
stop: () => void
30+
isRunning: () => boolean
31+
} {
32+
let observer: MutationObserver | null = null
33+
let timeoutId: number | null = null
34+
let running = false
35+
let inFlight = false
36+
let previous: A11yAuditResult | null = null
37+
38+
let unsubscribeVisibility: (() => void) | null = null
39+
40+
const schedule = () => {
41+
if (!running) return
42+
43+
if (timeoutId != null) {
44+
window.clearTimeout(timeoutId)
45+
}
46+
47+
timeoutId = window.setTimeout(async () => {
48+
timeoutId = null
49+
50+
if (!running) return
51+
if (inFlight) return
52+
53+
inFlight = true
54+
55+
try {
56+
const result = await runAudit({
57+
context: opts.context ?? document,
58+
threshold: opts.threshold,
59+
disabledRules: opts.disabledRules,
60+
})
61+
62+
cb.onResults(result)
63+
64+
const filteredPrev =
65+
previous == null
66+
? null
67+
: {
68+
...previous,
69+
issues: filterByThreshold(
70+
previous.issues,
71+
opts.threshold,
72+
).filter((issue) => !opts.disabledRules.includes(issue.ruleId)),
73+
}
74+
75+
const filteredCurrent: A11yAuditResult = {
76+
...result,
77+
issues: filterByThreshold(result.issues, opts.threshold).filter(
78+
(issue) => !opts.disabledRules.includes(issue.ruleId),
79+
),
80+
}
81+
82+
if (cb.onDiff) {
83+
cb.onDiff(diffAuditResults(filteredPrev, filteredCurrent))
84+
}
85+
86+
previous = result
87+
} finally {
88+
inFlight = false
89+
}
90+
}, opts.delay)
91+
}
92+
93+
const start = () => {
94+
if (running || typeof MutationObserver === 'undefined') return
95+
running = true
96+
97+
const contextNode = (() => {
98+
const ctx = opts.context
99+
if (!ctx || ctx === document) return document.documentElement
100+
if (typeof ctx === 'string') return document.querySelector(ctx)
101+
if (ctx instanceof Element) return ctx
102+
if (ctx instanceof Document) return ctx.documentElement
103+
return document.documentElement
104+
})()
105+
106+
const target = contextNode ?? document.documentElement
107+
108+
observer = new MutationObserver(() => {
109+
if (document.hidden) return
110+
schedule()
111+
})
112+
113+
observer.observe(target, {
114+
subtree: true,
115+
childList: true,
116+
attributes: true,
117+
})
118+
119+
const onVisibilityChange = () => {
120+
if (document.hidden) {
121+
if (timeoutId != null) {
122+
window.clearTimeout(timeoutId)
123+
timeoutId = null
124+
}
125+
return
126+
}
127+
schedule()
128+
}
129+
130+
document.addEventListener('visibilitychange', onVisibilityChange)
131+
unsubscribeVisibility = () => {
132+
document.removeEventListener('visibilitychange', onVisibilityChange)
133+
}
134+
135+
schedule()
136+
}
137+
138+
const stop = () => {
139+
running = false
140+
141+
if (timeoutId != null) {
142+
window.clearTimeout(timeoutId)
143+
timeoutId = null
144+
}
145+
146+
if (observer) {
147+
observer.disconnect()
148+
observer = null
149+
}
150+
151+
if (unsubscribeVisibility) {
152+
unsubscribeVisibility()
153+
unsubscribeVisibility = null
154+
}
155+
156+
inFlight = false
157+
}
158+
159+
return {
160+
start,
161+
stop,
162+
isRunning: () => running,
163+
}
164+
}

packages/devtools-a11y/src/overlay/highlight.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,9 @@ const SEVERITY_COLORS: Record<
7474
*/
7575
function injectStyles(): void {
7676
if (document.getElementById(HIGHLIGHT_STYLE_ID)) {
77-
console.log('[A11y Overlay] Styles already injected')
7877
return
7978
}
8079

81-
console.log('[A11y Overlay] Injecting styles into document.head')
8280
const style = document.createElement('style')
8381
style.id = HIGHLIGHT_STYLE_ID
8482
// Highlights use outline which doesn't affect layout
@@ -354,11 +352,6 @@ export function highlightElement(
354352
* Shows all issues per element in the tooltip, using the most severe for highlighting.
355353
*/
356354
export function highlightAllIssues(issues: Array<A11yIssue>): void {
357-
console.log(
358-
'[A11y Overlay] highlightAllIssues called with',
359-
issues.length,
360-
'issues',
361-
)
362355
injectStyles()
363356
clearHighlights()
364357

@@ -383,11 +376,6 @@ export function highlightAllIssues(issues: Array<A11yIssue>): void {
383376
}
384377

385378
// Highlight each selector with its most severe issue, but show all in tooltip
386-
console.log(
387-
'[A11y Overlay] Processing',
388-
selectorIssues.size,
389-
'unique selectors',
390-
)
391379
for (const [selector, issueList] of selectorIssues) {
392380
// Skip empty lists (shouldn't happen, but guards against undefined)
393381
if (issueList.length === 0) {
@@ -401,9 +389,6 @@ export function highlightAllIssues(issues: Array<A11yIssue>): void {
401389

402390
try {
403391
const elements = document.querySelectorAll(selector)
404-
console.log(
405-
`[A11y Overlay] Selector "${selector}" matched ${elements.length} elements`,
406-
)
407392
if (elements.length === 0) {
408393
continue
409394
}
@@ -412,11 +397,9 @@ export function highlightAllIssues(issues: Array<A11yIssue>): void {
412397
elements.forEach((el) => {
413398
// Skip elements inside devtools
414399
if (isInsideDevtools(el)) {
415-
console.log(`[A11y Overlay] Skipping element inside devtools:`, el)
416400
return
417401
}
418402

419-
console.log(`[A11y Overlay] Adding highlight class to element:`, el)
420403
el.classList.add(
421404
HIGHLIGHT_CLASS,
422405
`${HIGHLIGHT_CLASS}--${mostSevereImpact}`,

0 commit comments

Comments
 (0)