@ngbracket/a11y-devtools 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/dist/attribution.d.ts +17 -0
- package/dist/attribution.js +15 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/overlay.d.ts +29 -0
- package/dist/overlay.js +148 -0
- package/dist/provider.d.ts +24 -0
- package/dist/provider.js +44 -0
- package/dist/report.d.ts +14 -0
- package/dist/report.js +27 -0
- package/dist/runner.d.ts +10 -0
- package/dist/runner.js +10 -0
- package/dist/scan.d.ts +22 -0
- package/dist/scan.js +46 -0
- package/package.json +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @ngbracket/a11y-devtools
|
|
2
|
+
|
|
3
|
+
Dev-only, in-app accessibility auditing for Angular that maps each axe violation
|
|
4
|
+
back to **the component that rendered it** — the attribution React overlay tools
|
|
5
|
+
(`@axe-core/react`, `axe-mode`, TanStack a11y) structurally can't do.
|
|
6
|
+
|
|
7
|
+
Report `♿ UserCardComponent — 2 issue(s)` instead of a wall of CSS selectors.
|
|
8
|
+
|
|
9
|
+
Part of the `@ngbracket` Angular tooling family. See
|
|
10
|
+
[`docs/findings.md`](docs/findings.md) for the research and scope.
|
|
11
|
+
|
|
12
|
+
## Status
|
|
13
|
+
|
|
14
|
+
Ships attribution + axe scan + grouped console reporter + the dev-only provider
|
|
15
|
+
+ the visual in-app overlay (severity-coloured highlights, click-to-scroll).
|
|
16
|
+
|
|
17
|
+
## How the attribution works
|
|
18
|
+
|
|
19
|
+
Angular publishes debug helpers on the `window.ng` global in dev mode.
|
|
20
|
+
`getOwningComponent(node)` returns the component whose view contains a DOM node,
|
|
21
|
+
so any axe-flagged element resolves to its owning component. These helpers exist
|
|
22
|
+
**only in dev builds** — which is exactly right: the tool is dev-only, and the
|
|
23
|
+
global's absence in prod is the signal to no-op.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm i -D @ngbracket/a11y-devtools
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Peer deps: `@angular/core >=18`, `rxjs >=7`.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// app.config.ts (dev configuration)
|
|
37
|
+
import { provideA11yDevtools } from '@ngbracket/a11y-devtools';
|
|
38
|
+
|
|
39
|
+
export const appConfig = {
|
|
40
|
+
providers: [
|
|
41
|
+
// ...your providers
|
|
42
|
+
provideA11yDevtools(),
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
It rescans whenever the app settles (zoneless-aware, via `ApplicationRef.isStable`)
|
|
48
|
+
and logs violations grouped by owning component. Options:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
provideA11yDevtools({
|
|
52
|
+
root: () => document.querySelector('main')!, // scan scope; default document
|
|
53
|
+
log: true, // grouped console output; default true
|
|
54
|
+
overlay: true, // in-app visual highlights over flagged nodes; default false
|
|
55
|
+
debounceMs: 500, // quiet window after stabilization before scanning
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
You can also scan on demand:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { runA11yScan, scan } from '@ngbracket/a11y-devtools';
|
|
63
|
+
|
|
64
|
+
const findings = await runA11yScan(); // scan + grouped log
|
|
65
|
+
const raw = await scan(document.body); // findings only
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Production weight
|
|
69
|
+
|
|
70
|
+
In production `provideA11yDevtools()` is a **no-op** and axe-core is never loaded.
|
|
71
|
+
axe-core is a **dynamic import**, so it lands in a lazy chunk that prod never
|
|
72
|
+
fetches, and the package is `sideEffects: false` so an unused import tree-shakes
|
|
73
|
+
away entirely. For a hard guarantee, include the provider only in your dev
|
|
74
|
+
bootstrap config (e.g. behind `isDevMode()`).
|
|
75
|
+
|
|
76
|
+
This is **enforced in CI**: `src/testing/prod-weight.spec.ts` bundles the entry
|
|
77
|
+
with esbuild and walks the module graph — axe-core must be reachable *only*
|
|
78
|
+
through a dynamic import, never a static one. Turning `import('axe-core')` into a
|
|
79
|
+
static import (or adding a top-level side effect) fails the build.
|
|
80
|
+
|
|
81
|
+
## Develop
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install --legacy-peer-deps
|
|
85
|
+
npm test # vitest + jsdom + Angular TestBed (real axe)
|
|
86
|
+
npm run build # tsc -> dist/ (ESM + .d.ts)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Roadmap
|
|
90
|
+
|
|
91
|
+
- **`host` / `hostDirectives` a11y** via `getDirectives(el)` — the runtime cases
|
|
92
|
+
the item-2 lint plugin can't see statically.
|
|
93
|
+
- Per-component filtering and a violation count badge.
|
|
94
|
+
|
|
95
|
+
Done: attribution · axe scan · grouped console reporter · dev-only provider ·
|
|
96
|
+
in-app overlay · CI prod-weight guard.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribution core: given a DOM node that axe flagged, return the name of the
|
|
3
|
+
* component responsible for it — the differentiator React overlay tools can't do.
|
|
4
|
+
*
|
|
5
|
+
* Angular's debug helpers are NOT public named exports of `@angular/core`; they
|
|
6
|
+
* are methods on the `window.ng` global published in dev mode
|
|
7
|
+
* (`publishDefaultGlobalUtils`). `getComponent` resolves a component's host
|
|
8
|
+
* element to its instance; `getOwningComponent` resolves any inner node to the
|
|
9
|
+
* component whose view contains it. Try host first, then owner. The global's
|
|
10
|
+
* absence is the non-dev signal — callers should treat `null` as "not available".
|
|
11
|
+
*/
|
|
12
|
+
export interface NgDebugGlobal {
|
|
13
|
+
getComponent(element: Element): unknown;
|
|
14
|
+
getOwningComponent(element: Element | object): unknown;
|
|
15
|
+
}
|
|
16
|
+
export declare function ngDebug(): NgDebugGlobal | undefined;
|
|
17
|
+
export declare function resolveOwningComponentName(node: Element): string | null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function ngDebug() {
|
|
2
|
+
return globalThis.ng;
|
|
3
|
+
}
|
|
4
|
+
function nameOf(instance) {
|
|
5
|
+
if (!instance || typeof instance !== 'object')
|
|
6
|
+
return null;
|
|
7
|
+
return instance.constructor.name ?? null;
|
|
8
|
+
}
|
|
9
|
+
export function resolveOwningComponentName(node) {
|
|
10
|
+
const ng = ngDebug();
|
|
11
|
+
if (!ng)
|
|
12
|
+
return null;
|
|
13
|
+
const component = ng.getComponent(node) ?? ng.getOwningComponent(node);
|
|
14
|
+
return nameOf(component);
|
|
15
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { provideA11yDevtools, type A11yDevtoolsOptions } from './provider';
|
|
2
|
+
export { runA11yScan, type RunOptions } from './runner';
|
|
3
|
+
export { scan, type A11yFinding, type Impact } from './scan';
|
|
4
|
+
export { logFindings, type Logger } from './report';
|
|
5
|
+
export { resolveOwningComponentName, ngDebug, type NgDebugGlobal } from './attribution';
|
|
6
|
+
export { createOverlay, OVERLAY_ATTR, OVERLAY_EXCLUDE_SELECTOR, type A11yOverlay, type OverlayOptions, } from './overlay';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { provideA11yDevtools } from './provider';
|
|
2
|
+
export { runA11yScan } from './runner';
|
|
3
|
+
export { scan } from './scan';
|
|
4
|
+
export { logFindings } from './report';
|
|
5
|
+
export { resolveOwningComponentName, ngDebug } from './attribution';
|
|
6
|
+
export { createOverlay, OVERLAY_ATTR, OVERLAY_EXCLUDE_SELECTOR, } from './overlay';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { A11yFinding } from './scan';
|
|
2
|
+
/**
|
|
3
|
+
* Attribute marking the overlay's own DOM. `scan()` excludes anything under it
|
|
4
|
+
* so the tool never reports violations against its own highlights.
|
|
5
|
+
*/
|
|
6
|
+
export declare const OVERLAY_ATTR = "data-ngb-a11y-overlay";
|
|
7
|
+
/** Selector form of {@link OVERLAY_ATTR}, passed to axe as an exclude. */
|
|
8
|
+
export declare const OVERLAY_EXCLUDE_SELECTOR = "[data-ngb-a11y-overlay]";
|
|
9
|
+
export interface A11yOverlay {
|
|
10
|
+
/** Draw a highlight over each finding's node, replacing the previous set. */
|
|
11
|
+
render(findings: A11yFinding[]): void;
|
|
12
|
+
/** Remove all highlights but keep the overlay live. */
|
|
13
|
+
clear(): void;
|
|
14
|
+
/** Tear down: remove the container and detach scroll/resize listeners. */
|
|
15
|
+
destroy(): void;
|
|
16
|
+
}
|
|
17
|
+
export interface OverlayOptions {
|
|
18
|
+
/** Document to render into. Defaults to the global `document`. */
|
|
19
|
+
document?: Document;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A dev-only visual overlay: absolutely-positioned boxes drawn over the nodes
|
|
23
|
+
* axe flagged, coloured by impact, click-to-scroll to the offending element.
|
|
24
|
+
* Plain DOM — no Angular component — so it stays framework-agnostic, carries no
|
|
25
|
+
* change-detection cost, and its own DOM is trivially excluded from scans via
|
|
26
|
+
* {@link OVERLAY_ATTR}. Boxes reposition on scroll/resize so they track their
|
|
27
|
+
* targets as the page moves.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createOverlay(options?: OverlayOptions): A11yOverlay;
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribute marking the overlay's own DOM. `scan()` excludes anything under it
|
|
3
|
+
* so the tool never reports violations against its own highlights.
|
|
4
|
+
*/
|
|
5
|
+
export const OVERLAY_ATTR = 'data-ngb-a11y-overlay';
|
|
6
|
+
/** Selector form of {@link OVERLAY_ATTR}, passed to axe as an exclude. */
|
|
7
|
+
export const OVERLAY_EXCLUDE_SELECTOR = `[${OVERLAY_ATTR}]`;
|
|
8
|
+
/** Border/label colour per axe impact. `null` impact falls back to `none`. */
|
|
9
|
+
const IMPACT_COLOR = {
|
|
10
|
+
critical: '#d32029',
|
|
11
|
+
serious: '#e8710a',
|
|
12
|
+
moderate: '#c9a227',
|
|
13
|
+
minor: '#3b7dd8',
|
|
14
|
+
none: '#8a8a8a',
|
|
15
|
+
};
|
|
16
|
+
function colorFor(impact) {
|
|
17
|
+
return IMPACT_COLOR[impact ?? 'none'];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A dev-only visual overlay: absolutely-positioned boxes drawn over the nodes
|
|
21
|
+
* axe flagged, coloured by impact, click-to-scroll to the offending element.
|
|
22
|
+
* Plain DOM — no Angular component — so it stays framework-agnostic, carries no
|
|
23
|
+
* change-detection cost, and its own DOM is trivially excluded from scans via
|
|
24
|
+
* {@link OVERLAY_ATTR}. Boxes reposition on scroll/resize so they track their
|
|
25
|
+
* targets as the page moves.
|
|
26
|
+
*/
|
|
27
|
+
export function createOverlay(options = {}) {
|
|
28
|
+
const doc = options.document ?? document;
|
|
29
|
+
const container = doc.createElement('div');
|
|
30
|
+
container.setAttribute(OVERLAY_ATTR, '');
|
|
31
|
+
Object.assign(container.style, {
|
|
32
|
+
position: 'fixed',
|
|
33
|
+
inset: '0',
|
|
34
|
+
pointerEvents: 'none', // pass clicks through; individual boxes opt back in
|
|
35
|
+
zIndex: '2147483646',
|
|
36
|
+
});
|
|
37
|
+
doc.body.appendChild(container);
|
|
38
|
+
let highlights = [];
|
|
39
|
+
const reposition = () => {
|
|
40
|
+
for (const { target, box } of highlights) {
|
|
41
|
+
positionBox(box, target);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
// rAF-throttle so a stream of scroll events collapses to one layout read.
|
|
45
|
+
const raf = doc.defaultView?.requestAnimationFrame?.bind(doc.defaultView);
|
|
46
|
+
let scheduled = false;
|
|
47
|
+
const onViewportChange = () => {
|
|
48
|
+
if (!raf) {
|
|
49
|
+
reposition();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (scheduled)
|
|
53
|
+
return;
|
|
54
|
+
scheduled = true;
|
|
55
|
+
raf(() => {
|
|
56
|
+
scheduled = false;
|
|
57
|
+
reposition();
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const win = doc.defaultView;
|
|
61
|
+
win?.addEventListener('scroll', onViewportChange, { passive: true, capture: true });
|
|
62
|
+
win?.addEventListener('resize', onViewportChange, { passive: true });
|
|
63
|
+
function clear() {
|
|
64
|
+
for (const { box } of highlights)
|
|
65
|
+
box.remove();
|
|
66
|
+
highlights = [];
|
|
67
|
+
}
|
|
68
|
+
function render(findings) {
|
|
69
|
+
clear();
|
|
70
|
+
for (const finding of findings) {
|
|
71
|
+
const target = resolveTarget(doc, finding.target);
|
|
72
|
+
if (!target)
|
|
73
|
+
continue; // node gone since the scan (e.g. re-rendered)
|
|
74
|
+
const box = buildBox(doc, finding);
|
|
75
|
+
box.addEventListener('click', () => flashAndScroll(target, box));
|
|
76
|
+
container.appendChild(box);
|
|
77
|
+
positionBox(box, target);
|
|
78
|
+
highlights.push({ finding, target, box });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function destroy() {
|
|
82
|
+
clear();
|
|
83
|
+
win?.removeEventListener('scroll', onViewportChange, { capture: true });
|
|
84
|
+
win?.removeEventListener('resize', onViewportChange);
|
|
85
|
+
container.remove();
|
|
86
|
+
}
|
|
87
|
+
return { render, clear, destroy };
|
|
88
|
+
}
|
|
89
|
+
/** Resolve a finding's target selector to a node, tolerating a bad selector. */
|
|
90
|
+
function resolveTarget(doc, target) {
|
|
91
|
+
try {
|
|
92
|
+
return doc.querySelector(target);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function buildBox(doc, finding) {
|
|
99
|
+
const color = colorFor(finding.impact);
|
|
100
|
+
const box = doc.createElement('div');
|
|
101
|
+
box.setAttribute('data-impact', finding.impact ?? 'none');
|
|
102
|
+
box.title = `${finding.id}: ${finding.help}`;
|
|
103
|
+
Object.assign(box.style, {
|
|
104
|
+
position: 'fixed',
|
|
105
|
+
boxSizing: 'border-box',
|
|
106
|
+
border: `2px solid ${color}`,
|
|
107
|
+
borderRadius: '2px',
|
|
108
|
+
background: `${color}1a`, // ~10% alpha
|
|
109
|
+
pointerEvents: 'auto',
|
|
110
|
+
cursor: 'pointer',
|
|
111
|
+
});
|
|
112
|
+
const label = doc.createElement('span');
|
|
113
|
+
label.textContent = finding.component
|
|
114
|
+
? `${finding.component} · ${finding.id}`
|
|
115
|
+
: finding.id;
|
|
116
|
+
Object.assign(label.style, {
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
top: '0',
|
|
119
|
+
left: '0',
|
|
120
|
+
transform: 'translateY(-100%)',
|
|
121
|
+
padding: '1px 4px',
|
|
122
|
+
font: '11px/1.4 ui-monospace, monospace',
|
|
123
|
+
color: '#fff',
|
|
124
|
+
background: color,
|
|
125
|
+
whiteSpace: 'nowrap',
|
|
126
|
+
});
|
|
127
|
+
box.appendChild(label);
|
|
128
|
+
return box;
|
|
129
|
+
}
|
|
130
|
+
/** Anchor `box` (position:fixed) over `target` using viewport coordinates. */
|
|
131
|
+
function positionBox(box, target) {
|
|
132
|
+
const rect = target.getBoundingClientRect();
|
|
133
|
+
Object.assign(box.style, {
|
|
134
|
+
top: `${rect.top}px`,
|
|
135
|
+
left: `${rect.left}px`,
|
|
136
|
+
width: `${rect.width}px`,
|
|
137
|
+
height: `${rect.height}px`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** Scroll the offending node into view and briefly emphasise its highlight. */
|
|
141
|
+
function flashAndScroll(target, box) {
|
|
142
|
+
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
143
|
+
const original = box.style.boxShadow;
|
|
144
|
+
box.style.boxShadow = '0 0 0 4px rgba(255,255,255,0.6)';
|
|
145
|
+
box.ownerDocument.defaultView?.setTimeout(() => {
|
|
146
|
+
box.style.boxShadow = original;
|
|
147
|
+
}, 600);
|
|
148
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type EnvironmentProviders } from '@angular/core';
|
|
2
|
+
import type { Logger } from './report';
|
|
3
|
+
export interface A11yDevtoolsOptions {
|
|
4
|
+
/** Element/Document to scan. Defaults to `document`. */
|
|
5
|
+
root?: () => Element | Document;
|
|
6
|
+
/** Log grouped findings to the console. Default true. */
|
|
7
|
+
log?: boolean;
|
|
8
|
+
/** Sink for reporting; defaults to `console`. */
|
|
9
|
+
logger?: Logger;
|
|
10
|
+
/** Draw an in-app visual overlay over each flagged node. Default false. */
|
|
11
|
+
overlay?: boolean;
|
|
12
|
+
/** Quiet window after stabilization before scanning. Default 500ms. */
|
|
13
|
+
debounceMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Dev-only in-app accessibility auditing. Rescans whenever the application
|
|
17
|
+
* settles (zoneless-aware, via `ApplicationRef.isStable`) and reports each
|
|
18
|
+
* violation against the component that rendered it.
|
|
19
|
+
*
|
|
20
|
+
* In production this is a no-op and axe-core is never loaded, so it carries no
|
|
21
|
+
* runtime weight. For a hard guarantee, include the provider only in your dev
|
|
22
|
+
* bootstrap config.
|
|
23
|
+
*/
|
|
24
|
+
export declare function provideA11yDevtools(options?: A11yDevtoolsOptions): EnvironmentProviders;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ApplicationRef, DestroyRef, inject, isDevMode, makeEnvironmentProviders, provideEnvironmentInitializer, } from '@angular/core';
|
|
2
|
+
import { debounceTime, filter } from 'rxjs';
|
|
3
|
+
import { runA11yScan } from './runner';
|
|
4
|
+
import { createOverlay } from './overlay';
|
|
5
|
+
/**
|
|
6
|
+
* Dev-only in-app accessibility auditing. Rescans whenever the application
|
|
7
|
+
* settles (zoneless-aware, via `ApplicationRef.isStable`) and reports each
|
|
8
|
+
* violation against the component that rendered it.
|
|
9
|
+
*
|
|
10
|
+
* In production this is a no-op and axe-core is never loaded, so it carries no
|
|
11
|
+
* runtime weight. For a hard guarantee, include the provider only in your dev
|
|
12
|
+
* bootstrap config.
|
|
13
|
+
*/
|
|
14
|
+
export function provideA11yDevtools(options = {}) {
|
|
15
|
+
if (!isDevMode()) {
|
|
16
|
+
return makeEnvironmentProviders([]);
|
|
17
|
+
}
|
|
18
|
+
const { root, log = true, logger, overlay = false, debounceMs = 500 } = options;
|
|
19
|
+
return makeEnvironmentProviders([
|
|
20
|
+
provideEnvironmentInitializer(() => {
|
|
21
|
+
const appRef = inject(ApplicationRef);
|
|
22
|
+
const destroyRef = inject(DestroyRef);
|
|
23
|
+
const overlayView = overlay ? createOverlay() : undefined;
|
|
24
|
+
let scanning = false;
|
|
25
|
+
const subscription = appRef.isStable
|
|
26
|
+
.pipe(filter((stable) => stable), debounceTime(debounceMs))
|
|
27
|
+
.subscribe(() => {
|
|
28
|
+
if (scanning)
|
|
29
|
+
return; // don't stack rescans while one is in flight
|
|
30
|
+
scanning = true;
|
|
31
|
+
runA11yScan(root?.(), { log, logger })
|
|
32
|
+
.then((findings) => overlayView?.render(findings))
|
|
33
|
+
.catch(() => undefined)
|
|
34
|
+
.finally(() => {
|
|
35
|
+
scanning = false;
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
destroyRef.onDestroy(() => {
|
|
39
|
+
subscription.unsubscribe();
|
|
40
|
+
overlayView?.destroy();
|
|
41
|
+
});
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { A11yFinding } from './scan';
|
|
2
|
+
/** Minimal console-shaped sink, so reporting is testable without the real console. */
|
|
3
|
+
export interface Logger {
|
|
4
|
+
groupCollapsed(label: string): void;
|
|
5
|
+
groupEnd(): void;
|
|
6
|
+
warn(...args: unknown[]): void;
|
|
7
|
+
info(...args: unknown[]): void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Log findings grouped by the component that owns them — the readable form the
|
|
11
|
+
* attribution makes possible ("♿ UserCardComponent — 2 issues" beats a list of
|
|
12
|
+
* CSS selectors).
|
|
13
|
+
*/
|
|
14
|
+
export declare function logFindings(findings: A11yFinding[], logger?: Logger): void;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log findings grouped by the component that owns them — the readable form the
|
|
3
|
+
* attribution makes possible ("♿ UserCardComponent — 2 issues" beats a list of
|
|
4
|
+
* CSS selectors).
|
|
5
|
+
*/
|
|
6
|
+
export function logFindings(findings, logger = console) {
|
|
7
|
+
if (findings.length === 0) {
|
|
8
|
+
logger.info('♿ a11y-devtools: no violations found');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const byComponent = new Map();
|
|
12
|
+
for (const finding of findings) {
|
|
13
|
+
const key = finding.component ?? '(unknown component)';
|
|
14
|
+
const bucket = byComponent.get(key);
|
|
15
|
+
if (bucket)
|
|
16
|
+
bucket.push(finding);
|
|
17
|
+
else
|
|
18
|
+
byComponent.set(key, [finding]);
|
|
19
|
+
}
|
|
20
|
+
for (const [component, items] of byComponent) {
|
|
21
|
+
logger.groupCollapsed(`♿ ${component} — ${items.length} issue(s)`);
|
|
22
|
+
for (const finding of items) {
|
|
23
|
+
logger.warn(`${finding.impact ?? 'n/a'} · ${finding.id}: ${finding.help}`, `\n ${finding.target}\n ${finding.helpUrl}`);
|
|
24
|
+
}
|
|
25
|
+
logger.groupEnd();
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type A11yFinding } from './scan';
|
|
2
|
+
import { type Logger } from './report';
|
|
3
|
+
export interface RunOptions {
|
|
4
|
+
/** Log grouped findings to the console. Default true. */
|
|
5
|
+
log?: boolean;
|
|
6
|
+
/** Sink for reporting; defaults to `console`. */
|
|
7
|
+
logger?: Logger;
|
|
8
|
+
}
|
|
9
|
+
/** Scan `root`, optionally report, and return the findings. */
|
|
10
|
+
export declare function runA11yScan(root?: Element | Document, options?: RunOptions): Promise<A11yFinding[]>;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { scan } from './scan';
|
|
2
|
+
import { logFindings } from './report';
|
|
3
|
+
/** Scan `root`, optionally report, and return the findings. */
|
|
4
|
+
export async function runA11yScan(root, options = {}) {
|
|
5
|
+
const findings = await scan(root ?? document);
|
|
6
|
+
if (options.log !== false) {
|
|
7
|
+
logFindings(findings, options.logger);
|
|
8
|
+
}
|
|
9
|
+
return findings;
|
|
10
|
+
}
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RunOptions } from 'axe-core';
|
|
2
|
+
export type Impact = 'minor' | 'moderate' | 'serious' | 'critical' | null;
|
|
3
|
+
/** One axe violation node, enriched with the component that rendered it. */
|
|
4
|
+
export interface A11yFinding {
|
|
5
|
+
/** axe rule id, e.g. `image-alt`. */
|
|
6
|
+
id: string;
|
|
7
|
+
impact: Impact;
|
|
8
|
+
help: string;
|
|
9
|
+
helpUrl: string;
|
|
10
|
+
/** Owning component name, or null when attribution isn't available (prod). */
|
|
11
|
+
component: string | null;
|
|
12
|
+
/** CSS selector axe reported for the node. */
|
|
13
|
+
target: string;
|
|
14
|
+
html: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run axe over `root` and return findings enriched with owning component names.
|
|
18
|
+
* axe-core is loaded via dynamic import so a prod build never pulls it into the
|
|
19
|
+
* main bundle (the provider also no-ops outside dev mode). Concurrent calls are
|
|
20
|
+
* serialized because axe cannot run more than once at a time.
|
|
21
|
+
*/
|
|
22
|
+
export declare function scan(root?: Element | Document, options?: RunOptions): Promise<A11yFinding[]>;
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolveOwningComponentName } from './attribution';
|
|
2
|
+
import { OVERLAY_EXCLUDE_SELECTOR } from './overlay';
|
|
3
|
+
// axe-core is a singleton and throws if a run starts while another is in flight.
|
|
4
|
+
// Chain runs so overlapping callers (e.g. rapid rescans) serialize safely.
|
|
5
|
+
let runChain = Promise.resolve();
|
|
6
|
+
/**
|
|
7
|
+
* Run axe over `root` and return findings enriched with owning component names.
|
|
8
|
+
* axe-core is loaded via dynamic import so a prod build never pulls it into the
|
|
9
|
+
* main bundle (the provider also no-ops outside dev mode). Concurrent calls are
|
|
10
|
+
* serialized because axe cannot run more than once at a time.
|
|
11
|
+
*/
|
|
12
|
+
export function scan(root = document, options) {
|
|
13
|
+
const result = runChain.then(() => runAxeOnce(root, options));
|
|
14
|
+
runChain = result.catch(() => undefined);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
async function runAxeOnce(root, options) {
|
|
18
|
+
const axe = (await import('axe-core')).default;
|
|
19
|
+
// Scan within `root` but never flag the overlay's own highlights.
|
|
20
|
+
const context = { include: root, exclude: [OVERLAY_EXCLUDE_SELECTOR] };
|
|
21
|
+
const results = await axe.run(context, options ?? {});
|
|
22
|
+
const doc = root instanceof Document ? root : (root.ownerDocument ?? document);
|
|
23
|
+
const findings = [];
|
|
24
|
+
for (const violation of results.violations) {
|
|
25
|
+
for (const node of violation.nodes) {
|
|
26
|
+
const target = Array.isArray(node.target) ? String(node.target[0]) : String(node.target);
|
|
27
|
+
let element = null;
|
|
28
|
+
try {
|
|
29
|
+
element = doc.querySelector(target);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
element = null;
|
|
33
|
+
}
|
|
34
|
+
findings.push({
|
|
35
|
+
id: violation.id,
|
|
36
|
+
impact: (violation.impact ?? null),
|
|
37
|
+
help: violation.help,
|
|
38
|
+
helpUrl: violation.helpUrl,
|
|
39
|
+
component: element ? resolveOwningComponentName(element) : null,
|
|
40
|
+
target,
|
|
41
|
+
html: node.html,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ngbracket/a11y-devtools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dev-only in-app accessibility auditing for Angular that maps each axe violation back to the component that rendered it — the attribution React overlay tools can't do.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Duncan Faulkner",
|
|
7
|
+
"contributors": [
|
|
8
|
+
"Lara Sanz"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://ngbracket.com",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/ngbracket/a11y-devtools"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"angular",
|
|
17
|
+
"accessibility",
|
|
18
|
+
"a11y",
|
|
19
|
+
"devtools",
|
|
20
|
+
"axe-core",
|
|
21
|
+
"axe",
|
|
22
|
+
"ngbracket"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.json",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"prepublishOnly": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"axe-core": "^4.13.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@angular/core": ">=18.0.0",
|
|
51
|
+
"rxjs": ">=7.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@analogjs/vite-plugin-angular": "^2.7.2",
|
|
55
|
+
"@analogjs/vitest-angular": "^2.7.2",
|
|
56
|
+
"@angular/build": "^22.1.7",
|
|
57
|
+
"@angular/common": "^22.1.5",
|
|
58
|
+
"@angular/compiler": "^22.1.5",
|
|
59
|
+
"@angular/compiler-cli": "^22.1.5",
|
|
60
|
+
"@angular/core": "^22.1.5",
|
|
61
|
+
"@angular/platform-browser": "^22.1.5",
|
|
62
|
+
"@angular/platform-browser-dynamic": "^22.1.5",
|
|
63
|
+
"@oxc-project/runtime": "^0.149.0",
|
|
64
|
+
"@types/node": "^26.5.0",
|
|
65
|
+
"jsdom": "^30.0.1",
|
|
66
|
+
"rxjs": "^7.8.2",
|
|
67
|
+
"tslib": "^2.8.1",
|
|
68
|
+
"typescript": "^6.0.3",
|
|
69
|
+
"vitest": "^4.1.11"
|
|
70
|
+
}
|
|
71
|
+
}
|