@okent/accessibility-scanner-keyboard-plugin 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +122 -2
- package/dist/index.d.ts +22 -0
- package/dist/index.js +71 -0
- package/dist/src/config.d.ts +16 -0
- package/dist/src/config.js +41 -0
- package/dist/src/probe.d.ts +33 -0
- package/dist/src/probe.js +299 -0
- package/dist/src/rules.d.ts +14 -0
- package/dist/src/rules.js +43 -0
- package/dist/src/scripts.d.ts +11 -0
- package/dist/src/scripts.js +228 -0
- package/dist/src/walk.d.ts +45 -0
- package/dist/src/walk.js +93 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 owenpkent
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Keyboard probe plugin for github/accessibility-scanner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A plugin for [GitHub's accessibility scanner](https://github.com/github/accessibility-scanner)
|
|
4
|
+
that walks the real Tab order of each page and reports the keyboard failures a
|
|
5
|
+
rule engine inspecting the DOM at rest cannot see. It is a TypeScript port of
|
|
6
|
+
the keyboard probe in accessibility-tooling, an ACR toolkit, with the same rules, the
|
|
7
|
+
same thresholds, and the same refusal to treat a clean walk as a pass.
|
|
8
|
+
|
|
9
|
+
| Rule | Criterion | Asserts |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `keyboard-trap` | 2.1.2 No Keyboard Trap | failure: Tab cycles inside a subset of the page and Escape does not release it |
|
|
12
|
+
| `keyboard-unreachable` | 2.1.1 Keyboard | failure: a visible interactive element never receives focus during a complete walk |
|
|
13
|
+
| `focus-not-visible` | 2.4.7 Focus Visible | failure: screenshots of an element focused and unfocused are pixel-identical (text-entry elements are flagged for review instead, since the caret may be the only indicator and screenshots do not capture it) |
|
|
14
|
+
| `focus-order-vs-dom` | 2.4.3 Focus Order | review only: the Tab order jumps backwards through the DOM, usually a positive `tabindex` |
|
|
15
|
+
|
|
16
|
+
Review-only findings carry a `needs review:` prefix in the issue title, the
|
|
17
|
+
convention the scanner's own reflow plugin uses.
|
|
18
|
+
|
|
19
|
+
## What a finding does and does not mean
|
|
20
|
+
|
|
21
|
+
A detected failure is real evidence: the trap was walked into and Escape was
|
|
22
|
+
pressed, the control was never focused across a complete walk, the pixels did
|
|
23
|
+
not change. A clean walk proves nothing. The page may trap focus under a key
|
|
24
|
+
the probe never presses, a control may be reachable only through a shortcut
|
|
25
|
+
the probe does not know, and an indicator that is present may still be too
|
|
26
|
+
faint to see. Treat silence from this plugin as "not detected", never as
|
|
27
|
+
"conforms".
|
|
28
|
+
|
|
29
|
+
Two limits are logged rather than reported, because the scanner has no
|
|
30
|
+
info-level finding:
|
|
31
|
+
|
|
32
|
+
- **Iframes are not entered.** Their contents are not walked. The log line
|
|
33
|
+
says how many frames were skipped.
|
|
34
|
+
- **Closed shadow roots are invisible.** Open shadow roots are walked through
|
|
35
|
+
the composed tree and their controls are named with `>>>` selectors.
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
### As an npm plugin
|
|
40
|
+
|
|
41
|
+
Once the package is on the scanner's npm allowlist:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- uses: github/accessibility-scanner@v3
|
|
45
|
+
with:
|
|
46
|
+
scans: |
|
|
47
|
+
["axe", {"name": "keyboard-scan", "package": "@okent/accessibility-scanner-keyboard-plugin", "version": "0.1.0"}]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### As a local plugin
|
|
51
|
+
|
|
52
|
+
Copy this folder to `.github/scanner-plugins/keyboard-scan/` in the repository
|
|
53
|
+
that runs the workflow, check the repository out before the scanner step, and
|
|
54
|
+
list the plugin by name. The scanner bundles `index.ts` with esbuild at run
|
|
55
|
+
time. There are no runtime dependencies to install; Playwright comes from the
|
|
56
|
+
scanner.
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/checkout@v6
|
|
61
|
+
- uses: github/accessibility-scanner@v3
|
|
62
|
+
with:
|
|
63
|
+
scans: '["axe", "keyboard-scan"]'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Configuration
|
|
67
|
+
|
|
68
|
+
Optional, at `.github/scanner-plugins/keyboard-scan/config.json`:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"stepDelayMs": 60,
|
|
73
|
+
"maxShots": 40,
|
|
74
|
+
"maxUnreachable": 8
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`stepDelayMs` is the wait after each Tab press before focus is read; raise it
|
|
79
|
+
for pages with slow focus handlers. `maxShots` caps the per-page focus
|
|
80
|
+
screenshots, which bound the run time of the visibility check. `maxUnreachable`
|
|
81
|
+
caps individual unreachable-element findings per page; the rest are folded
|
|
82
|
+
into one summary finding.
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
1. Enumerate candidate controls in the composed tree, scoped to the open modal
|
|
87
|
+
if one is visible, skipping anything hidden, inert, disabled or
|
|
88
|
+
`aria-hidden`.
|
|
89
|
+
2. Blur, then press Tab repeatedly, recording which candidate holds focus after
|
|
90
|
+
each press and screenshotting each newly focused element with a 12px
|
|
91
|
+
margin.
|
|
92
|
+
3. When the sequence repeats with a full period twice, press Escape. If focus
|
|
93
|
+
leaves the cycle the container was dismissible and the walk continues. If
|
|
94
|
+
Escape closed the container, the page has changed under the walk and it
|
|
95
|
+
stops. Otherwise it is a trap, reported once by its common ancestor, and
|
|
96
|
+
the walk continues from the next focusable element with that jump marked
|
|
97
|
+
so it is not counted as an order divergence.
|
|
98
|
+
4. After a complete walk, report candidates that were never focused, except
|
|
99
|
+
those reachable through a focused ancestor or descendant, radio-group
|
|
100
|
+
members whose group was reached, and roving-tabindex composite members whose
|
|
101
|
+
composite was reached. Nothing past a trap is judged.
|
|
102
|
+
5. Report backwards jumps in the first-visit order, excluding jumps the probe
|
|
103
|
+
made itself.
|
|
104
|
+
6. Blur and re-screenshot each captured element unfocused. Identical bytes mean
|
|
105
|
+
focus produced no visible change.
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
npm install
|
|
111
|
+
npx playwright install chromium
|
|
112
|
+
npm test # unit tests for the walk analysis, plus an end-to-end run
|
|
113
|
+
# against test/fixtures/demo.html
|
|
114
|
+
npm run build
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The end-to-end test asserts that every keyboard defect planted in the
|
|
118
|
+
fixture is found and that its control cases stay silent. The
|
|
119
|
+
fixture's header comment lists what is planted.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { type ProbeFinding } from './src/probe.js';
|
|
3
|
+
export declare const name = "keyboard-scan";
|
|
4
|
+
/** The scanner's Finding input, as its plugin docs define it. */
|
|
5
|
+
export type FindingInput = {
|
|
6
|
+
scannerType: string;
|
|
7
|
+
category?: 'wcag' | 'best-practice' | 'experimental';
|
|
8
|
+
ruleId?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
html?: string;
|
|
11
|
+
problemShort: string;
|
|
12
|
+
problemUrl: string;
|
|
13
|
+
solutionShort: string;
|
|
14
|
+
solutionLong?: string;
|
|
15
|
+
};
|
|
16
|
+
export type PluginParams = {
|
|
17
|
+
page: Page;
|
|
18
|
+
addFinding: (finding: FindingInput) => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
/** Shape one probe result into the scanner's Finding. Exported for tests. */
|
|
21
|
+
export declare function toFinding(f: ProbeFinding, url: string): FindingInput;
|
|
22
|
+
export default function keyboardScan({ page, addFinding }: PluginParams): Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Keyboard probe plugin for github/accessibility-scanner.
|
|
2
|
+
//
|
|
3
|
+
// The scanner's engines inspect the DOM at rest. The failures that lock
|
|
4
|
+
// keyboard users out are behavioural: focus enters a widget and never leaves,
|
|
5
|
+
// a click-only control never receives focus at all, the focus indicator was
|
|
6
|
+
// styled away. This plugin drives the keyboard for real and reports exactly
|
|
7
|
+
// those, as a port of the keyboard probe in owenpkent/accessibility-tooling.
|
|
8
|
+
//
|
|
9
|
+
// keyboard-trap Tab cycles inside a subset of the page and Escape
|
|
10
|
+
// does not release it -> 2.1.2
|
|
11
|
+
// keyboard-unreachable a visible interactive element is never focusable
|
|
12
|
+
// -> 2.1.1
|
|
13
|
+
// focus-not-visible focused and unfocused screenshots of an element are
|
|
14
|
+
// pixel-identical -> 2.4.7
|
|
15
|
+
// (text-entry elements are flagged for review instead)
|
|
16
|
+
// focus-order-vs-dom the Tab order jumps backwards through the DOM
|
|
17
|
+
// -> 2.4.3, review
|
|
18
|
+
//
|
|
19
|
+
// A detected failure is real evidence. A clean walk proves nothing: the page
|
|
20
|
+
// may still trap focus under a key the probe never pressed, and a present
|
|
21
|
+
// indicator may still be too faint to see.
|
|
22
|
+
import { loadConfig } from './src/config.js';
|
|
23
|
+
import { probePage } from './src/probe.js';
|
|
24
|
+
import { RULES } from './src/rules.js';
|
|
25
|
+
export const name = 'keyboard-scan';
|
|
26
|
+
/** Shape one probe result into the scanner's Finding. Exported for tests. */
|
|
27
|
+
export function toFinding(f, url) {
|
|
28
|
+
const rule = RULES[f.ruleId];
|
|
29
|
+
// The scanner has no confidence field. Its own reflow plugin marks a
|
|
30
|
+
// review-only finding with this prefix, so the issue title says so too.
|
|
31
|
+
const prefix = f.confidence === 'review' ? 'needs review: ' : '';
|
|
32
|
+
const occurrences = f.occurrences > 1 ? ` (${f.occurrences} occurrences)` : '';
|
|
33
|
+
return {
|
|
34
|
+
scannerType: name,
|
|
35
|
+
category: 'wcag',
|
|
36
|
+
ruleId: f.ruleId,
|
|
37
|
+
url,
|
|
38
|
+
html: f.html,
|
|
39
|
+
problemShort: `${prefix}${rule.problemShort}${occurrences}`,
|
|
40
|
+
problemUrl: rule.problemUrl,
|
|
41
|
+
solutionShort: rule.solutionShort,
|
|
42
|
+
solutionLong: `WCAG 2.2 ${rule.wcagSc}. Selector: \`${f.selector}\`\n\n${f.detail}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export default async function keyboardScan({ page, addFinding }) {
|
|
46
|
+
const cfg = loadConfig();
|
|
47
|
+
const url = page.url();
|
|
48
|
+
const { findings, notes } = await probePage(page, cfg);
|
|
49
|
+
if (notes.shadowRootsTraversed) {
|
|
50
|
+
console.info(`[keyboard-scan] ${url}: ${notes.shadowRootsTraversed} open shadow root(s) traversed`);
|
|
51
|
+
}
|
|
52
|
+
if (notes.framesNotWalked) {
|
|
53
|
+
// Silence about a page says nothing about its frames; say so where the
|
|
54
|
+
// run log is read, since the scanner has no info-level finding.
|
|
55
|
+
console.warn(`[keyboard-scan] ${url}: ${notes.framesNotWalked} iframe(s) NOT walked; ` +
|
|
56
|
+
`Tab order, traps and focus indicators inside them were not checked`);
|
|
57
|
+
}
|
|
58
|
+
if (notes.dismissed) {
|
|
59
|
+
console.warn(`[keyboard-scan] ${url}: Escape closed a container mid-walk, so the walk stopped there; ` +
|
|
60
|
+
`reachability and focus visibility were not judged on this page`);
|
|
61
|
+
}
|
|
62
|
+
else if (!notes.walkComplete && !notes.trapsFound) {
|
|
63
|
+
console.warn(`[keyboard-scan] ${url}: the walk did not complete; reachability was not judged`);
|
|
64
|
+
}
|
|
65
|
+
for (const f of findings) {
|
|
66
|
+
await addFinding(toFinding(f, url));
|
|
67
|
+
}
|
|
68
|
+
console.info(`[keyboard-scan] ${url}: ${notes.candidates} candidate(s), ` +
|
|
69
|
+
`${findings.filter(f => f.confidence === 'violation').length} violation(s), ` +
|
|
70
|
+
`${findings.filter(f => f.confidence === 'review').length} flagged for review`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type ProbeConfig = {
|
|
2
|
+
/** ms to wait after each Tab press before reading focus. */
|
|
3
|
+
stepDelayMs: number;
|
|
4
|
+
/** Cap on per-element focus screenshots per page. */
|
|
5
|
+
maxShots: number;
|
|
6
|
+
/** Cap on individual unreachable-element findings per page. */
|
|
7
|
+
maxUnreachable: number;
|
|
8
|
+
};
|
|
9
|
+
export declare const DEFAULTS: ProbeConfig;
|
|
10
|
+
/**
|
|
11
|
+
* Optional overrides from `.github/scanner-plugins/keyboard-scan/config.json`
|
|
12
|
+
* in the repository the workflow runs from, which is where the scanner's own
|
|
13
|
+
* plugins read theirs. A missing or malformed file falls back to the defaults;
|
|
14
|
+
* unknown keys and non-numeric values are ignored and logged.
|
|
15
|
+
*/
|
|
16
|
+
export declare function loadConfig(cwd?: string): ProbeConfig;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
export const DEFAULTS = {
|
|
4
|
+
stepDelayMs: 60,
|
|
5
|
+
maxShots: 40,
|
|
6
|
+
maxUnreachable: 8,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Optional overrides from `.github/scanner-plugins/keyboard-scan/config.json`
|
|
10
|
+
* in the repository the workflow runs from, which is where the scanner's own
|
|
11
|
+
* plugins read theirs. A missing or malformed file falls back to the defaults;
|
|
12
|
+
* unknown keys and non-numeric values are ignored and logged.
|
|
13
|
+
*/
|
|
14
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
15
|
+
const file = path.join(cwd, '.github', 'scanner-plugins', 'keyboard-scan', 'config.json');
|
|
16
|
+
const out = { ...DEFAULTS };
|
|
17
|
+
if (!fs.existsSync(file))
|
|
18
|
+
return out;
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
console.warn(`[keyboard-scan] ignoring malformed ${file}: ${e}`);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
28
|
+
return out;
|
|
29
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
30
|
+
if (!(key in DEFAULTS)) {
|
|
31
|
+
console.warn(`[keyboard-scan] ignoring unknown config key ${key}`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
35
|
+
console.warn(`[keyboard-scan] ignoring non-numeric config value for ${key}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
out[key] = value;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { type RuleId } from './rules.js';
|
|
3
|
+
import type { ProbeConfig } from './config.js';
|
|
4
|
+
/** One probe result before it is shaped into the scanner's Finding. */
|
|
5
|
+
export type ProbeFinding = {
|
|
6
|
+
ruleId: RuleId;
|
|
7
|
+
confidence: 'violation' | 'review';
|
|
8
|
+
selector: string;
|
|
9
|
+
html: string;
|
|
10
|
+
detail: string;
|
|
11
|
+
occurrences: number;
|
|
12
|
+
};
|
|
13
|
+
/** Facts about the walk that are not findings but that a reader must know. */
|
|
14
|
+
export type ProbeNotes = {
|
|
15
|
+
shadowRootsTraversed: number;
|
|
16
|
+
framesNotWalked: number;
|
|
17
|
+
candidates: number;
|
|
18
|
+
walkComplete: boolean;
|
|
19
|
+
trapsFound: number;
|
|
20
|
+
dismissed: boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Walk the real Tab order of the page as it currently stands and report what
|
|
24
|
+
* a rule engine inspecting the DOM at rest cannot see. The page is left with
|
|
25
|
+
* focus wherever the walk ended; callers that run other scans afterwards
|
|
26
|
+
* should not depend on focus position.
|
|
27
|
+
*/
|
|
28
|
+
export declare function probePage(page: Page, cfg: ProbeConfig): Promise<{
|
|
29
|
+
findings: ProbeFinding[];
|
|
30
|
+
notes: ProbeNotes;
|
|
31
|
+
}>;
|
|
32
|
+
/** The criterion a rule supplies evidence for, for callers shaping output. */
|
|
33
|
+
export declare function criterionFor(ruleId: RuleId): string;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { RULES } from './rules.js';
|
|
2
|
+
import { detectCycle, isPositiveTabindex, nextFocusableAfter, orderDivergences, unreachableCandidates, } from './walk.js';
|
|
3
|
+
import * as S from './scripts.js';
|
|
4
|
+
// Padding around an element's box when screenshotting it, so outlines and
|
|
5
|
+
// shadows drawn outside the border box are inside the frame. An indicator
|
|
6
|
+
// offset further out than this is invisible to the comparison, which errs
|
|
7
|
+
// toward missing a defect rather than inventing one.
|
|
8
|
+
const SHOT_PAD = 12;
|
|
9
|
+
// Node Playwright evaluates a string as an expression and does not call it
|
|
10
|
+
// with an argument, so wrap the arrow-function source in a call with the
|
|
11
|
+
// argument inlined. Arguments here are small: indices and index lists.
|
|
12
|
+
function evalScript(page, script, arg) {
|
|
13
|
+
const call = arg === undefined ? `(${script})()` : `(${script})(${JSON.stringify(arg)})`;
|
|
14
|
+
return page.evaluate(call);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Screenshot the focused element's neighbourhood, viewport-relative.
|
|
18
|
+
*
|
|
19
|
+
* Returns null when the element is offscreen: comparing frames that show
|
|
20
|
+
* other content would manufacture false verdicts, and a missed comparison
|
|
21
|
+
* only costs a detection, never invents one.
|
|
22
|
+
*/
|
|
23
|
+
async function captureFocused(page, idx) {
|
|
24
|
+
const r = await evalScript(page, S.RECT, idx);
|
|
25
|
+
const x = Math.max(r.x - SHOT_PAD, 0);
|
|
26
|
+
const y = Math.max(r.y - SHOT_PAD, 0);
|
|
27
|
+
const w = Math.min(r.x + r.w + SHOT_PAD, r.vw) - x;
|
|
28
|
+
const h = Math.min(r.y + r.h + SHOT_PAD, r.vh) - y;
|
|
29
|
+
if (w < 4 || h < 4)
|
|
30
|
+
return null;
|
|
31
|
+
const clip = { x, y, width: w, height: h };
|
|
32
|
+
try {
|
|
33
|
+
const png = await page.screenshot({ clip, animations: 'disabled' });
|
|
34
|
+
return { clip, sx: r.sx, sy: r.sy, png };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function sameMembers(a, b) {
|
|
41
|
+
if (a.length !== b.length)
|
|
42
|
+
return false;
|
|
43
|
+
const sa = new Set(a);
|
|
44
|
+
return b.every(v => sa.has(v));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Walk the real Tab order of the page as it currently stands and report what
|
|
48
|
+
* a rule engine inspecting the DOM at rest cannot see. The page is left with
|
|
49
|
+
* focus wherever the walk ended; callers that run other scans afterwards
|
|
50
|
+
* should not depend on focus position.
|
|
51
|
+
*/
|
|
52
|
+
export async function probePage(page, cfg) {
|
|
53
|
+
const findings = [];
|
|
54
|
+
const cands = await evalScript(page, S.ENUMERATE);
|
|
55
|
+
const shadowRootsTraversed = await evalScript(page, S.SHADOW_COUNT);
|
|
56
|
+
const framesNotWalked = await evalScript(page, S.FRAME_COUNT);
|
|
57
|
+
const notes = {
|
|
58
|
+
shadowRootsTraversed,
|
|
59
|
+
framesNotWalked,
|
|
60
|
+
candidates: cands.length,
|
|
61
|
+
walkComplete: false,
|
|
62
|
+
trapsFound: 0,
|
|
63
|
+
dismissed: false,
|
|
64
|
+
};
|
|
65
|
+
if (!cands.length)
|
|
66
|
+
return { findings, notes };
|
|
67
|
+
const n = cands.length;
|
|
68
|
+
await evalScript(page, S.BLUR);
|
|
69
|
+
const visited = new Set();
|
|
70
|
+
const firstVisit = [];
|
|
71
|
+
const barriers = new Set();
|
|
72
|
+
let seq = [];
|
|
73
|
+
const shots = new Map();
|
|
74
|
+
const traps = [];
|
|
75
|
+
const escapedCycles = [];
|
|
76
|
+
let dismissed = false;
|
|
77
|
+
let walkComplete = false;
|
|
78
|
+
let stray = 0;
|
|
79
|
+
let wraps = 0;
|
|
80
|
+
const maxSteps = 2 * n + 15;
|
|
81
|
+
let step = 0;
|
|
82
|
+
const noteVisit = async (idx) => {
|
|
83
|
+
if (idx >= 0 && !visited.has(idx)) {
|
|
84
|
+
visited.add(idx);
|
|
85
|
+
firstVisit.push(idx);
|
|
86
|
+
if (shots.size < cfg.maxShots) {
|
|
87
|
+
const shot = await captureFocused(page, idx);
|
|
88
|
+
if (shot)
|
|
89
|
+
shots.set(idx, shot);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
while (step < maxSteps) {
|
|
94
|
+
step++;
|
|
95
|
+
await page.keyboard.press('Tab');
|
|
96
|
+
await page.waitForTimeout(cfg.stepDelayMs);
|
|
97
|
+
const info = await evalScript(page, S.ACTIVE);
|
|
98
|
+
if (info.wrapped) {
|
|
99
|
+
// The walk does not necessarily start at the top of the page. One more
|
|
100
|
+
// pass picks up whatever came before the starting point; a second wrap
|
|
101
|
+
// means everything reachable has been seen.
|
|
102
|
+
wraps++;
|
|
103
|
+
const unvisitedFocusable = cands.some(c => c.focusable && !visited.has(c.i));
|
|
104
|
+
if (wraps >= 2 || !unvisitedFocusable) {
|
|
105
|
+
walkComplete = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
barriers.add(firstVisit.length);
|
|
109
|
+
seq = [];
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const idx = info.idx;
|
|
113
|
+
seq.push(idx);
|
|
114
|
+
if (idx < 0) {
|
|
115
|
+
// Focus is somewhere the enumeration cannot see. Tolerate a stretch of
|
|
116
|
+
// it; give up honestly rather than loop forever.
|
|
117
|
+
stray++;
|
|
118
|
+
if (stray > 25)
|
|
119
|
+
break;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
stray = 0;
|
|
123
|
+
await noteVisit(idx);
|
|
124
|
+
const cyc = detectCycle(seq);
|
|
125
|
+
if (!cyc || escapedCycles.some(e => sameMembers(e, cyc)))
|
|
126
|
+
continue;
|
|
127
|
+
if (new Set(cyc).size >= n) {
|
|
128
|
+
// The whole page cycling is wrap behaviour, not a trap.
|
|
129
|
+
walkComplete = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (cyc.every(i => cands[i].tag === 'iframe' || cands[i].tag === 'frame')) {
|
|
133
|
+
// Focus is progressing inside an embedded document; from out here it
|
|
134
|
+
// looks stationary. Step past it and keep walking.
|
|
135
|
+
const nxt = nextFocusableAfter(cands, Math.max(...cyc), visited);
|
|
136
|
+
if (nxt === null) {
|
|
137
|
+
walkComplete = true;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
await evalScript(page, S.FOCUS, nxt);
|
|
141
|
+
barriers.add(firstVisit.length);
|
|
142
|
+
seq = [];
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// Candidate trap. The one exit a probe can honestly press is Escape: if
|
|
146
|
+
// that releases focus, this is a dismissible container behaving itself.
|
|
147
|
+
await page.keyboard.press('Escape');
|
|
148
|
+
await page.waitForTimeout(120);
|
|
149
|
+
await page.keyboard.press('Tab');
|
|
150
|
+
await page.waitForTimeout(cfg.stepDelayMs);
|
|
151
|
+
const info2 = await evalScript(page, S.ACTIVE);
|
|
152
|
+
if (info2.wrapped || !cyc.includes(info2.idx)) {
|
|
153
|
+
const stillVisible = await evalScript(page, S.ALL_VISIBLE, cyc);
|
|
154
|
+
if (!stillVisible) {
|
|
155
|
+
// Escape closed the container. The page has mutated under the walk,
|
|
156
|
+
// so stop here; stale geometry is not evidence.
|
|
157
|
+
dismissed = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
escapedCycles.push(cyc);
|
|
161
|
+
seq = [];
|
|
162
|
+
if (!info2.wrapped) {
|
|
163
|
+
barriers.add(firstVisit.length);
|
|
164
|
+
await noteVisit(info2.idx);
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
traps.push(cyc);
|
|
169
|
+
const anc = await evalScript(page, S.COMMON_ANCESTOR, cyc);
|
|
170
|
+
const members = cyc.map(i => `\`${cands[i].selector}\``).join(', ');
|
|
171
|
+
findings.push({
|
|
172
|
+
ruleId: 'keyboard-trap',
|
|
173
|
+
confidence: 'violation',
|
|
174
|
+
selector: anc.selector,
|
|
175
|
+
html: anc.html,
|
|
176
|
+
detail: `Tab cycles between ${members} and never leaves. Escape was pressed and did not ` +
|
|
177
|
+
`release focus. Keyboard and switch users cannot reach anything after this point; ` +
|
|
178
|
+
`the rest of this walk was continued by moving focus programmatically, which a user cannot do.`,
|
|
179
|
+
occurrences: 1,
|
|
180
|
+
});
|
|
181
|
+
const nxt = nextFocusableAfter(cands, Math.max(...cyc), visited);
|
|
182
|
+
if (nxt === null)
|
|
183
|
+
break;
|
|
184
|
+
await evalScript(page, S.FOCUS, nxt);
|
|
185
|
+
barriers.add(firstVisit.length);
|
|
186
|
+
seq = [];
|
|
187
|
+
}
|
|
188
|
+
notes.walkComplete = walkComplete;
|
|
189
|
+
notes.trapsFound = traps.length;
|
|
190
|
+
notes.dismissed = dismissed;
|
|
191
|
+
// -- reachability ----------------------------------------------------------
|
|
192
|
+
if ((walkComplete || traps.length) && !dismissed) {
|
|
193
|
+
const before = traps.length ? Math.min(...traps.map(t => Math.min(...t))) : null;
|
|
194
|
+
const unvisited = cands.filter(c => !visited.has(c.i)).map(c => c.i);
|
|
195
|
+
const contained = new Set(unvisited.length
|
|
196
|
+
? await evalScript(page, S.CONTAINED, [unvisited, [...visited].sort((a, b) => a - b)])
|
|
197
|
+
: []);
|
|
198
|
+
const missed = unreachableCandidates(cands, visited, contained, before);
|
|
199
|
+
for (const c of missed.slice(0, cfg.maxUnreachable)) {
|
|
200
|
+
const why = c.tabindex === '-1'
|
|
201
|
+
? 'it has tabindex="-1" and no focusable alternative'
|
|
202
|
+
: !c.focusable
|
|
203
|
+
? 'it is not focusable at all'
|
|
204
|
+
: 'the browser never moved focus to it during a complete walk';
|
|
205
|
+
findings.push({
|
|
206
|
+
ruleId: 'keyboard-unreachable',
|
|
207
|
+
confidence: 'violation',
|
|
208
|
+
selector: c.selector,
|
|
209
|
+
html: c.html,
|
|
210
|
+
detail: `This <${c.tag}> looks interactive but never received focus while tabbing through ` +
|
|
211
|
+
`the page: ${why}. Its function is unavailable to keyboard and switch users.`,
|
|
212
|
+
occurrences: 1,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (missed.length > cfg.maxUnreachable) {
|
|
216
|
+
const rest = missed.slice(cfg.maxUnreachable);
|
|
217
|
+
findings.push({
|
|
218
|
+
ruleId: 'keyboard-unreachable',
|
|
219
|
+
confidence: 'violation',
|
|
220
|
+
selector: rest[0].selector,
|
|
221
|
+
html: rest[0].html,
|
|
222
|
+
detail: `${rest.length} further element(s) were never reachable by keyboard on this page. ` +
|
|
223
|
+
`First few: ${rest
|
|
224
|
+
.slice(0, 5)
|
|
225
|
+
.map(c => `\`${c.selector}\``)
|
|
226
|
+
.join('; ')}`,
|
|
227
|
+
occurrences: rest.length,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// -- focus order -----------------------------------------------------------
|
|
232
|
+
const jumps = orderDivergences(firstVisit, barriers);
|
|
233
|
+
if (jumps.length) {
|
|
234
|
+
const positive = cands.filter(c => isPositiveTabindex(c.tabindex));
|
|
235
|
+
const lines = jumps
|
|
236
|
+
.slice(0, 8)
|
|
237
|
+
.map(([a, b]) => `- focus moved to \`${cands[b].selector}\` (DOM position ${b}) after ` +
|
|
238
|
+
`\`${cands[a].selector}\` (DOM position ${a})`);
|
|
239
|
+
if (positive.length) {
|
|
240
|
+
lines.push('Elements with a positive tabindex, the usual cause: ' +
|
|
241
|
+
positive
|
|
242
|
+
.slice(0, 5)
|
|
243
|
+
.map(c => `\`${c.selector}\``)
|
|
244
|
+
.join(', '));
|
|
245
|
+
}
|
|
246
|
+
const first = jumps[0][1];
|
|
247
|
+
findings.push({
|
|
248
|
+
ruleId: 'focus-order-vs-dom',
|
|
249
|
+
confidence: 'review',
|
|
250
|
+
selector: cands[first].selector,
|
|
251
|
+
html: cands[first].html,
|
|
252
|
+
detail: 'The Tab order jumps backwards through the document:\n' +
|
|
253
|
+
lines.join('\n') +
|
|
254
|
+
'\nWhether this order still preserves meaning and operability is a human judgement; ' +
|
|
255
|
+
'review it against the visual layout.',
|
|
256
|
+
occurrences: jumps.length,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
// -- focus visibility ------------------------------------------------------
|
|
260
|
+
if (shots.size && !dismissed) {
|
|
261
|
+
await evalScript(page, S.BLUR);
|
|
262
|
+
await page.waitForTimeout(150);
|
|
263
|
+
for (const idx of [...shots.keys()].sort((a, b) => a - b)) {
|
|
264
|
+
const shot = shots.get(idx);
|
|
265
|
+
await evalScript(page, S.SCROLL_TO, [shot.sx, shot.sy]);
|
|
266
|
+
await page.waitForTimeout(30);
|
|
267
|
+
let after;
|
|
268
|
+
try {
|
|
269
|
+
after = await page.screenshot({ clip: shot.clip, animations: 'disabled' });
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!after.equals(shot.png))
|
|
275
|
+
continue;
|
|
276
|
+
const c = cands[idx];
|
|
277
|
+
findings.push({
|
|
278
|
+
ruleId: 'focus-not-visible',
|
|
279
|
+
// The caret, which screenshots do not capture, may be a text field's
|
|
280
|
+
// only indicator; ask rather than assert.
|
|
281
|
+
confidence: c.editable ? 'review' : 'violation',
|
|
282
|
+
selector: c.selector,
|
|
283
|
+
html: c.html,
|
|
284
|
+
detail: 'Screenshots of this element focused and unfocused are pixel-identical (including a ' +
|
|
285
|
+
'margin around it), so keyboard focus produces no visible change at all.' +
|
|
286
|
+
(c.editable
|
|
287
|
+
? ' This is a text-entry element, so the caret, which screenshots do not capture, ' +
|
|
288
|
+
'may be its only indicator; review by hand.'
|
|
289
|
+
: ' A sighted keyboard user has no way to tell where they are on the page.'),
|
|
290
|
+
occurrences: 1,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { findings, notes };
|
|
295
|
+
}
|
|
296
|
+
/** The criterion a rule supplies evidence for, for callers shaping output. */
|
|
297
|
+
export function criterionFor(ruleId) {
|
|
298
|
+
return RULES[ruleId].wcagSc;
|
|
299
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type RuleId = 'keyboard-trap' | 'keyboard-unreachable' | 'focus-not-visible' | 'focus-order-vs-dom';
|
|
2
|
+
export type Rule = {
|
|
3
|
+
/** WCAG 2.2 success criterion the rule supplies evidence for. */
|
|
4
|
+
wcagSc: string;
|
|
5
|
+
/** Lower-case fragment that completes "An accessibility scan flagged ... because ...". */
|
|
6
|
+
problemShort: string;
|
|
7
|
+
/** The W3C Understanding page. */
|
|
8
|
+
problemUrl: string;
|
|
9
|
+
/** Lower-case fragment that completes "To fix this, ...". */
|
|
10
|
+
solutionShort: string;
|
|
11
|
+
/** Whether a finding under this rule asserts a failure or only asks for a look. */
|
|
12
|
+
asserts: 'violation' | 'review';
|
|
13
|
+
};
|
|
14
|
+
export declare const RULES: Record<RuleId, Rule>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The four rules, and what each one asserts. These mirror lib/probe_rules.py
|
|
2
|
+
// in the parent repository; the reason text is the same so a finding reads
|
|
3
|
+
// the same whichever harness produced it.
|
|
4
|
+
//
|
|
5
|
+
// The asymmetry is the whole design: a detected failure is real evidence of
|
|
6
|
+
// non-conformance, a clean walk is NOT evidence of conformance. Every one of
|
|
7
|
+
// these checks observes a mechanical fragment of a criterion whose full
|
|
8
|
+
// meaning needs a human.
|
|
9
|
+
const UNDERSTANDING = 'https://www.w3.org/WAI/WCAG22/Understanding/';
|
|
10
|
+
export const RULES = {
|
|
11
|
+
'keyboard-trap': {
|
|
12
|
+
wcagSc: '2.1.2',
|
|
13
|
+
problemShort: 'sequential focus enters a set of elements it cannot leave, and Escape does not release it',
|
|
14
|
+
problemUrl: `${UNDERSTANDING}no-keyboard-trap`,
|
|
15
|
+
solutionShort: 'make sure focus can always be moved away with Tab or Shift+Tab, or document the exit; ' +
|
|
16
|
+
'if a widget repurposes Tab, Escape should leave it',
|
|
17
|
+
asserts: 'violation',
|
|
18
|
+
},
|
|
19
|
+
'keyboard-unreachable': {
|
|
20
|
+
wcagSc: '2.1.1',
|
|
21
|
+
problemShort: 'a visible interactive element can never receive keyboard focus',
|
|
22
|
+
problemUrl: `${UNDERSTANDING}keyboard`,
|
|
23
|
+
solutionShort: 'make every control reachable with the keyboard: use a native button or link, or add ' +
|
|
24
|
+
'tabindex="0" and key handling to the custom element',
|
|
25
|
+
asserts: 'violation',
|
|
26
|
+
},
|
|
27
|
+
'focus-not-visible': {
|
|
28
|
+
wcagSc: '2.4.7',
|
|
29
|
+
problemShort: 'an element shows no visible change at all when it receives keyboard focus',
|
|
30
|
+
problemUrl: `${UNDERSTANDING}focus-visible`,
|
|
31
|
+
solutionShort: 'restore a focus indicator (usually the removed outline) or provide a replacement with ' +
|
|
32
|
+
'sufficient contrast',
|
|
33
|
+
asserts: 'violation',
|
|
34
|
+
},
|
|
35
|
+
'focus-order-vs-dom': {
|
|
36
|
+
wcagSc: '2.4.3',
|
|
37
|
+
problemShort: 'the sequential focus order diverges from the DOM order',
|
|
38
|
+
problemUrl: `${UNDERSTANDING}focus-order`,
|
|
39
|
+
solutionShort: 'review the Tab order against the visual layout; remove positive tabindex values and ' +
|
|
40
|
+
'reorder the DOM instead if the order does not preserve meaning',
|
|
41
|
+
asserts: 'review',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const ENUMERATE: string;
|
|
2
|
+
export declare const SHADOW_COUNT = "() => {\n\n const queryAllComposed = (root, selector) => {\n const out = [];\n const visit = (node) => {\n if (node.nodeType !== 1) return;\n if (node.matches(selector)) out.push(node);\n if (node.shadowRoot) {\n for (const child of node.shadowRoot.children) visit(child);\n }\n for (const child of node.children) visit(child);\n };\n for (const child of root.children) visit(child);\n if (root.shadowRoot) {\n for (const child of root.shadowRoot.children) visit(child);\n }\n return out;\n };\n\n const countShadowRoots = (root) =>\n queryAllComposed(root, '*').filter((el) => !!el.shadowRoot).length\n + (root.shadowRoot ? 1 : 0);\n\n return countShadowRoots(document.body);\n}";
|
|
3
|
+
export declare const FRAME_COUNT = "() => {\n\n const queryAllComposed = (root, selector) => {\n const out = [];\n const visit = (node) => {\n if (node.nodeType !== 1) return;\n if (node.matches(selector)) out.push(node);\n if (node.shadowRoot) {\n for (const child of node.shadowRoot.children) visit(child);\n }\n for (const child of node.children) visit(child);\n };\n for (const child of root.children) visit(child);\n if (root.shadowRoot) {\n for (const child of root.shadowRoot.children) visit(child);\n }\n return out;\n };\n\n const countFrames = (root) => queryAllComposed(root, 'iframe,frame').length;\n\n return countFrames(document.body);\n}";
|
|
4
|
+
export declare const ACTIVE = "() => {\n\n const deepActiveElement = (doc) => {\n let el = doc.activeElement;\n while (el && el.shadowRoot && el.shadowRoot.activeElement) {\n el = el.shadowRoot.activeElement;\n }\n return el;\n };\n\n const p = window.__kbProbe || { els: [] };\n const ae = deepActiveElement(document);\n const wrapped = !ae || ae === document.body || ae === document.documentElement;\n return { idx: wrapped ? -1 : p.els.indexOf(ae), wrapped: wrapped };\n}";
|
|
5
|
+
export declare const RECT = "(i) => {\n const el = window.__kbProbe.els[i];\n const r = el.getBoundingClientRect();\n return { x: r.x, y: r.y, w: r.width, h: r.height,\n sx: window.scrollX, sy: window.scrollY,\n vw: window.innerWidth, vh: window.innerHeight };\n}";
|
|
6
|
+
export declare const ALL_VISIBLE = "(idxs) => {\n const p = window.__kbProbe || { els: [] };\n return idxs.every(i => {\n const el = p.els[i];\n if (!el || !el.isConnected) return false;\n const cs = getComputedStyle(el);\n if (cs.display === 'none' || cs.visibility !== 'visible') return false;\n const r = el.getBoundingClientRect();\n return r.width >= 2 && r.height >= 2;\n });\n}";
|
|
7
|
+
export declare const CONTAINED = "([unvisited, visited]) => {\n\n const composedParent = (node) => {\n if (!node) return null;\n if (node.assignedSlot) return node.assignedSlot;\n const p = node.parentNode;\n if (!p) return null;\n if (p.nodeType === 11) return p.host || null;\n return p;\n };\n const composedContains = (ancestor, node) => {\n for (let n = node; n; n = composedParent(n)) {\n if (n === ancestor) return true;\n }\n return false;\n };\n const composedClosest = (node, selector) => {\n for (let n = node; n; n = composedParent(n)) {\n if (n.nodeType === 1 && n.matches(selector)) return n;\n }\n return null;\n };\n\n const els = (window.__kbProbe || { els: [] }).els;\n const vis = visited.map(i => els[i]).filter(Boolean);\n return unvisited.filter(u => {\n const el = els[u];\n return el && vis.some(v => composedContains(el, v) || composedContains(v, el));\n });\n}";
|
|
8
|
+
export declare const FOCUS = "(i) => { const el = window.__kbProbe.els[i]; if (el) el.focus(); }";
|
|
9
|
+
export declare const BLUR = "() => {\n\n const deepActiveElement = (doc) => {\n let el = doc.activeElement;\n while (el && el.shadowRoot && el.shadowRoot.activeElement) {\n el = el.shadowRoot.activeElement;\n }\n return el;\n };\n\n const a = deepActiveElement(document); if (a && a.blur) a.blur();\n}";
|
|
10
|
+
export declare const SCROLL_TO = "([x, y]) => window.scrollTo(x, y)";
|
|
11
|
+
export declare const COMMON_ANCESTOR = "(idxs) => {\n\n const composedParent = (node) => {\n if (!node) return null;\n if (node.assignedSlot) return node.assignedSlot;\n const p = node.parentNode;\n if (!p) return null;\n if (p.nodeType === 11) return p.host || null;\n return p;\n };\n const composedContains = (ancestor, node) => {\n for (let n = node; n; n = composedParent(n)) {\n if (n === ancestor) return true;\n }\n return false;\n };\n const composedClosest = (node, selector) => {\n for (let n = node; n; n = composedParent(n)) {\n if (n.nodeType === 1 && n.matches(selector)) return n;\n }\n return null;\n };\n\n const p = window.__kbProbe;\n let anc = p.els[idxs[0]];\n for (const i of idxs.slice(1)) {\n while (anc && !composedContains(anc, p.els[i])) anc = composedParent(anc);\n }\n if (!anc) anc = document.body;\n return { selector: p.path(anc), html: (anc.outerHTML || '').slice(0, 400) };\n}";
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// In-page scripts, as source strings. Each is an arrow function; `evalScript`
|
|
2
|
+
// in probe.ts wraps one in a call expression with its argument inlined, so the
|
|
3
|
+
// same text runs under Node Playwright that keyboard_scan.py runs under
|
|
4
|
+
// Python Playwright. The element list lives on window.__kbProbe so every later
|
|
5
|
+
// query resolves the same nodes the enumeration saw.
|
|
6
|
+
//
|
|
7
|
+
// All of it walks the composed (flat) tree, so controls inside open shadow
|
|
8
|
+
// roots are enumerated, focused, and named. Closed roots are invisible by
|
|
9
|
+
// construction, and iframes are counted but not entered.
|
|
10
|
+
const QUERY_ALL = `
|
|
11
|
+
const queryAllComposed = (root, selector) => {
|
|
12
|
+
const out = [];
|
|
13
|
+
const visit = (node) => {
|
|
14
|
+
if (node.nodeType !== 1) return;
|
|
15
|
+
if (node.matches(selector)) out.push(node);
|
|
16
|
+
if (node.shadowRoot) {
|
|
17
|
+
for (const child of node.shadowRoot.children) visit(child);
|
|
18
|
+
}
|
|
19
|
+
for (const child of node.children) visit(child);
|
|
20
|
+
};
|
|
21
|
+
for (const child of root.children) visit(child);
|
|
22
|
+
if (root.shadowRoot) {
|
|
23
|
+
for (const child of root.shadowRoot.children) visit(child);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
`;
|
|
28
|
+
// document.activeElement stops at the shadow host; follow each root's own
|
|
29
|
+
// activeElement down until the chain stops moving.
|
|
30
|
+
const DEEP_ACTIVE_ELEMENT = `
|
|
31
|
+
const deepActiveElement = (doc) => {
|
|
32
|
+
let el = doc.activeElement;
|
|
33
|
+
while (el && el.shadowRoot && el.shadowRoot.activeElement) {
|
|
34
|
+
el = el.shadowRoot.activeElement;
|
|
35
|
+
}
|
|
36
|
+
return el;
|
|
37
|
+
};
|
|
38
|
+
`;
|
|
39
|
+
// Ancestry across shadow boundaries. closest() and contains() stop at a
|
|
40
|
+
// ShadowRoot; this climbs the flat tree the way an event does.
|
|
41
|
+
const COMPOSED_ANCESTRY = `
|
|
42
|
+
const composedParent = (node) => {
|
|
43
|
+
if (!node) return null;
|
|
44
|
+
if (node.assignedSlot) return node.assignedSlot;
|
|
45
|
+
const p = node.parentNode;
|
|
46
|
+
if (!p) return null;
|
|
47
|
+
if (p.nodeType === 11) return p.host || null;
|
|
48
|
+
return p;
|
|
49
|
+
};
|
|
50
|
+
const composedContains = (ancestor, node) => {
|
|
51
|
+
for (let n = node; n; n = composedParent(n)) {
|
|
52
|
+
if (n === ancestor) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
const composedClosest = (node, selector) => {
|
|
57
|
+
for (let n = node; n; n = composedParent(n)) {
|
|
58
|
+
if (n.nodeType === 1 && n.matches(selector)) return n;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
`;
|
|
63
|
+
// A selector a person can paste into devtools, one segment per shadow
|
|
64
|
+
// boundary joined with axe's `>>>` notation. String.raw keeps the regex
|
|
65
|
+
// backslash intact.
|
|
66
|
+
const COMPOSED_SELECTOR = String.raw `
|
|
67
|
+
const composedSelectorPath = (el) => {
|
|
68
|
+
const segmentFor = (node, stopAt) => {
|
|
69
|
+
const bits = [];
|
|
70
|
+
let n = node;
|
|
71
|
+
while (n && n.nodeType === 1 && n !== stopAt) {
|
|
72
|
+
let bit = n.localName;
|
|
73
|
+
if (n.id) { bits.unshift(bit + '#' + CSS.escape(n.id)); break; }
|
|
74
|
+
const cls = (typeof n.className === 'string')
|
|
75
|
+
? n.className.trim().split(/\s+/).filter(Boolean).slice(0, 2) : [];
|
|
76
|
+
if (cls.length) bit += '.' + cls.map((c) => CSS.escape(c)).join('.');
|
|
77
|
+
const parent = n.parentNode;
|
|
78
|
+
if (parent && parent.children) {
|
|
79
|
+
const same = Array.from(parent.children).filter((ch) => ch.localName === n.localName);
|
|
80
|
+
if (same.length > 1) bit += ':nth-of-type(' + (same.indexOf(n) + 1) + ')';
|
|
81
|
+
}
|
|
82
|
+
bits.unshift(bit);
|
|
83
|
+
n = n.parentElement;
|
|
84
|
+
}
|
|
85
|
+
return bits.join(' > ');
|
|
86
|
+
};
|
|
87
|
+
const segments = [];
|
|
88
|
+
let node = el;
|
|
89
|
+
while (node) {
|
|
90
|
+
const treeRoot = node.getRootNode();
|
|
91
|
+
const inShadow = treeRoot instanceof ShadowRoot;
|
|
92
|
+
const stopAt = inShadow ? null : (node.ownerDocument ? node.ownerDocument.body : null);
|
|
93
|
+
segments.unshift(segmentFor(node, stopAt));
|
|
94
|
+
if (!inShadow) break;
|
|
95
|
+
node = treeRoot.host;
|
|
96
|
+
}
|
|
97
|
+
return segments.join(' >>> ');
|
|
98
|
+
};
|
|
99
|
+
`;
|
|
100
|
+
const SHADOW_ROOT_COUNT = `
|
|
101
|
+
const countShadowRoots = (root) =>
|
|
102
|
+
queryAllComposed(root, '*').filter((el) => !!el.shadowRoot).length
|
|
103
|
+
+ (root.shadowRoot ? 1 : 0);
|
|
104
|
+
`;
|
|
105
|
+
const IFRAME_COUNT = `
|
|
106
|
+
const countFrames = (root) => queryAllComposed(root, 'iframe,frame').length;
|
|
107
|
+
`;
|
|
108
|
+
export const ENUMERATE = `() => {
|
|
109
|
+
${QUERY_ALL}${COMPOSED_ANCESTRY}${COMPOSED_SELECTOR}${SHADOW_ROOT_COUNT}
|
|
110
|
+
// dialog:modal is the well-behaved case; role alone is not proof of
|
|
111
|
+
// modality, but it is what a hand-rolled widget tends to expose instead of
|
|
112
|
+
// a real <dialog>. The first visible match wins.
|
|
113
|
+
const MODAL_SEL = 'dialog:modal, [role="dialog"], [role="alertdialog"], [aria-modal="true"]';
|
|
114
|
+
let modal = null;
|
|
115
|
+
for (const el of queryAllComposed(document.body, MODAL_SEL)) {
|
|
116
|
+
const mcs = getComputedStyle(el);
|
|
117
|
+
if (mcs.display === 'none' || mcs.visibility !== 'visible') continue;
|
|
118
|
+
const mr = el.getBoundingClientRect();
|
|
119
|
+
if (mr.width < 2 || mr.height < 2) continue;
|
|
120
|
+
modal = el;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
const root = modal || document.body;
|
|
124
|
+
const SEL = [
|
|
125
|
+
'a[href]', 'area[href]', 'button', 'input', 'select', 'textarea', 'summary',
|
|
126
|
+
'iframe', 'audio[controls]', 'video[controls]',
|
|
127
|
+
'[contenteditable="true"]', '[contenteditable=""]', '[tabindex]', '[onclick]',
|
|
128
|
+
'[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]',
|
|
129
|
+
'[role="tab"]', '[role="menuitem"]', '[role="menuitemcheckbox"]',
|
|
130
|
+
'[role="menuitemradio"]', '[role="option"]', '[role="switch"]',
|
|
131
|
+
'[role="textbox"]', '[role="combobox"]', '[role="slider"]',
|
|
132
|
+
'[role="spinbutton"]', '[role="searchbox"]'
|
|
133
|
+
].join(',');
|
|
134
|
+
const COMPOSITE = ['[role="radiogroup"]', '[role="toolbar"]', '[role="menu"]',
|
|
135
|
+
'[role="menubar"]', '[role="listbox"]', '[role="tablist"]', '[role="tree"]',
|
|
136
|
+
'[role="grid"]'].join(',');
|
|
137
|
+
const els = [];
|
|
138
|
+
for (const el of queryAllComposed(root, SEL)) {
|
|
139
|
+
if (el === document.body || el === document.documentElement) continue;
|
|
140
|
+
const cs = getComputedStyle(el);
|
|
141
|
+
if (cs.display === 'none' || cs.visibility !== 'visible') continue;
|
|
142
|
+
const r = el.getBoundingClientRect();
|
|
143
|
+
if (r.width < 2 || r.height < 2) continue;
|
|
144
|
+
if (el.disabled) continue;
|
|
145
|
+
if (composedClosest(el, '[aria-hidden="true"]')) continue;
|
|
146
|
+
if (composedClosest(el, '[inert]')) continue;
|
|
147
|
+
els.push(el);
|
|
148
|
+
}
|
|
149
|
+
window.__kbProbe = { els: els, path: composedSelectorPath };
|
|
150
|
+
const TEXTLIKE = ['button', 'submit', 'reset', 'checkbox', 'radio', 'range',
|
|
151
|
+
'color', 'file', 'image'];
|
|
152
|
+
return els.map((el, i) => {
|
|
153
|
+
const comp = composedClosest(el, COMPOSITE);
|
|
154
|
+
return {
|
|
155
|
+
i: i,
|
|
156
|
+
tag: el.localName,
|
|
157
|
+
selector: composedSelectorPath(el),
|
|
158
|
+
tabindex: el.getAttribute('tabindex'),
|
|
159
|
+
focusable: el.tabIndex >= 0,
|
|
160
|
+
type: (el.getAttribute('type') || '').toLowerCase(),
|
|
161
|
+
name: el.getAttribute('name') || '',
|
|
162
|
+
role: el.getAttribute('role') || '',
|
|
163
|
+
editable: el.localName === 'textarea' || el.isContentEditable ||
|
|
164
|
+
(el.localName === 'input' &&
|
|
165
|
+
!TEXTLIKE.includes((el.getAttribute('type') || 'text').toLowerCase())),
|
|
166
|
+
composite: comp ? composedSelectorPath(comp) : '',
|
|
167
|
+
html: (el.outerHTML || '').slice(0, 400),
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}`;
|
|
171
|
+
export const SHADOW_COUNT = `() => {
|
|
172
|
+
${QUERY_ALL}${SHADOW_ROOT_COUNT}
|
|
173
|
+
return countShadowRoots(document.body);
|
|
174
|
+
}`;
|
|
175
|
+
export const FRAME_COUNT = `() => {
|
|
176
|
+
${QUERY_ALL}${IFRAME_COUNT}
|
|
177
|
+
return countFrames(document.body);
|
|
178
|
+
}`;
|
|
179
|
+
export const ACTIVE = `() => {
|
|
180
|
+
${DEEP_ACTIVE_ELEMENT}
|
|
181
|
+
const p = window.__kbProbe || { els: [] };
|
|
182
|
+
const ae = deepActiveElement(document);
|
|
183
|
+
const wrapped = !ae || ae === document.body || ae === document.documentElement;
|
|
184
|
+
return { idx: wrapped ? -1 : p.els.indexOf(ae), wrapped: wrapped };
|
|
185
|
+
}`;
|
|
186
|
+
export const RECT = `(i) => {
|
|
187
|
+
const el = window.__kbProbe.els[i];
|
|
188
|
+
const r = el.getBoundingClientRect();
|
|
189
|
+
return { x: r.x, y: r.y, w: r.width, h: r.height,
|
|
190
|
+
sx: window.scrollX, sy: window.scrollY,
|
|
191
|
+
vw: window.innerWidth, vh: window.innerHeight };
|
|
192
|
+
}`;
|
|
193
|
+
export const ALL_VISIBLE = `(idxs) => {
|
|
194
|
+
const p = window.__kbProbe || { els: [] };
|
|
195
|
+
return idxs.every(i => {
|
|
196
|
+
const el = p.els[i];
|
|
197
|
+
if (!el || !el.isConnected) return false;
|
|
198
|
+
const cs = getComputedStyle(el);
|
|
199
|
+
if (cs.display === 'none' || cs.visibility !== 'visible') return false;
|
|
200
|
+
const r = el.getBoundingClientRect();
|
|
201
|
+
return r.width >= 2 && r.height >= 2;
|
|
202
|
+
});
|
|
203
|
+
}`;
|
|
204
|
+
export const CONTAINED = `([unvisited, visited]) => {
|
|
205
|
+
${COMPOSED_ANCESTRY}
|
|
206
|
+
const els = (window.__kbProbe || { els: [] }).els;
|
|
207
|
+
const vis = visited.map(i => els[i]).filter(Boolean);
|
|
208
|
+
return unvisited.filter(u => {
|
|
209
|
+
const el = els[u];
|
|
210
|
+
return el && vis.some(v => composedContains(el, v) || composedContains(v, el));
|
|
211
|
+
});
|
|
212
|
+
}`;
|
|
213
|
+
export const FOCUS = `(i) => { const el = window.__kbProbe.els[i]; if (el) el.focus(); }`;
|
|
214
|
+
export const BLUR = `() => {
|
|
215
|
+
${DEEP_ACTIVE_ELEMENT}
|
|
216
|
+
const a = deepActiveElement(document); if (a && a.blur) a.blur();
|
|
217
|
+
}`;
|
|
218
|
+
export const SCROLL_TO = `([x, y]) => window.scrollTo(x, y)`;
|
|
219
|
+
export const COMMON_ANCESTOR = `(idxs) => {
|
|
220
|
+
${COMPOSED_ANCESTRY}
|
|
221
|
+
const p = window.__kbProbe;
|
|
222
|
+
let anc = p.els[idxs[0]];
|
|
223
|
+
for (const i of idxs.slice(1)) {
|
|
224
|
+
while (anc && !composedContains(anc, p.els[i])) anc = composedParent(anc);
|
|
225
|
+
}
|
|
226
|
+
if (!anc) anc = document.body;
|
|
227
|
+
return { selector: p.path(anc), html: (anc.outerHTML || '').slice(0, 400) };
|
|
228
|
+
}`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type Candidate = {
|
|
2
|
+
i: number;
|
|
3
|
+
tag: string;
|
|
4
|
+
selector: string;
|
|
5
|
+
tabindex: string | null;
|
|
6
|
+
focusable: boolean;
|
|
7
|
+
type: string;
|
|
8
|
+
name: string;
|
|
9
|
+
role: string;
|
|
10
|
+
editable: boolean;
|
|
11
|
+
composite: string;
|
|
12
|
+
html: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Return the member indices of a focus cycle at the tail of `seq`, or null.
|
|
16
|
+
*
|
|
17
|
+
* A Tab walk never legitimately revisits an element (revisiting means focus
|
|
18
|
+
* looped), but a single revisit can also be a probe artifact, so this demands
|
|
19
|
+
* two full periods: the last 2p entries repeat with period p. Unknown elements
|
|
20
|
+
* (-1) never form a cycle; focus we cannot identify is not evidence.
|
|
21
|
+
*/
|
|
22
|
+
export declare function detectCycle(seq: number[], maxPeriod?: number): number[] | null;
|
|
23
|
+
/**
|
|
24
|
+
* (from, to) pairs where the first-visit order jumps backwards in the DOM.
|
|
25
|
+
*
|
|
26
|
+
* `barriers` holds positions in `firstVisit` where the probe moved focus
|
|
27
|
+
* programmatically (past an iframe or a trap); the jump across a barrier is
|
|
28
|
+
* the probe's doing, not the page's, so it is not a divergence.
|
|
29
|
+
*/
|
|
30
|
+
export declare function orderDivergences(firstVisit: number[], barriers?: Set<number>): Array<[number, number]>;
|
|
31
|
+
/**
|
|
32
|
+
* Candidates that never received focus and have no keyboard-reachable proxy.
|
|
33
|
+
*
|
|
34
|
+
* `contained` holds indices that wrap or sit inside a visited element (a card
|
|
35
|
+
* div with an onclick around a real link: the function is reachable through
|
|
36
|
+
* the link). Roving-tabindex composites and radio groups keep one tab stop by
|
|
37
|
+
* design, so an unvisited member whose group was reached is not a defect.
|
|
38
|
+
* `beforeIndex` limits reporting to elements before a detected trap: past the
|
|
39
|
+
* trap, "unreachable" and "blocked by the trap" cannot be told apart, and the
|
|
40
|
+
* trap finding already owns that blockage.
|
|
41
|
+
*/
|
|
42
|
+
export declare function unreachableCandidates(cands: Candidate[], visited: Set<number>, contained: Set<number>, beforeIndex?: number | null): Candidate[];
|
|
43
|
+
export declare function nextFocusableAfter(cands: Candidate[], after: number, visited: Set<number>): number | null;
|
|
44
|
+
/** True for a tabindex attribute that is a positive integer. */
|
|
45
|
+
export declare function isPositiveTabindex(value: string | null): boolean;
|
package/dist/src/walk.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Pure walk analysis. No browser here, so test/walk.test.ts can exercise every
|
|
2
|
+
// branch without Playwright. A port of the same functions in keyboard_scan.py;
|
|
3
|
+
// if one changes, change the other.
|
|
4
|
+
/**
|
|
5
|
+
* Return the member indices of a focus cycle at the tail of `seq`, or null.
|
|
6
|
+
*
|
|
7
|
+
* A Tab walk never legitimately revisits an element (revisiting means focus
|
|
8
|
+
* looped), but a single revisit can also be a probe artifact, so this demands
|
|
9
|
+
* two full periods: the last 2p entries repeat with period p. Unknown elements
|
|
10
|
+
* (-1) never form a cycle; focus we cannot identify is not evidence.
|
|
11
|
+
*/
|
|
12
|
+
export function detectCycle(seq, maxPeriod = 12) {
|
|
13
|
+
const limit = Math.min(maxPeriod, Math.floor(seq.length / 2));
|
|
14
|
+
for (let p = 1; p <= limit; p++) {
|
|
15
|
+
const window = seq.slice(-2 * p);
|
|
16
|
+
const first = window.slice(0, p);
|
|
17
|
+
const second = window.slice(p);
|
|
18
|
+
const repeats = first.every((v, k) => v === second[k]);
|
|
19
|
+
const known = window.every(v => v >= 0);
|
|
20
|
+
const distinct = new Set(first).size === p;
|
|
21
|
+
if (repeats && known && distinct)
|
|
22
|
+
return Array.from(new Set(first));
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* (from, to) pairs where the first-visit order jumps backwards in the DOM.
|
|
28
|
+
*
|
|
29
|
+
* `barriers` holds positions in `firstVisit` where the probe moved focus
|
|
30
|
+
* programmatically (past an iframe or a trap); the jump across a barrier is
|
|
31
|
+
* the probe's doing, not the page's, so it is not a divergence.
|
|
32
|
+
*/
|
|
33
|
+
export function orderDivergences(firstVisit, barriers = new Set()) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (let pos = 1; pos < firstVisit.length; pos++) {
|
|
36
|
+
const prev = firstVisit[pos - 1];
|
|
37
|
+
const cur = firstVisit[pos];
|
|
38
|
+
if (cur < prev && !barriers.has(pos))
|
|
39
|
+
out.push([prev, cur]);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Candidates that never received focus and have no keyboard-reachable proxy.
|
|
45
|
+
*
|
|
46
|
+
* `contained` holds indices that wrap or sit inside a visited element (a card
|
|
47
|
+
* div with an onclick around a real link: the function is reachable through
|
|
48
|
+
* the link). Roving-tabindex composites and radio groups keep one tab stop by
|
|
49
|
+
* design, so an unvisited member whose group was reached is not a defect.
|
|
50
|
+
* `beforeIndex` limits reporting to elements before a detected trap: past the
|
|
51
|
+
* trap, "unreachable" and "blocked by the trap" cannot be told apart, and the
|
|
52
|
+
* trap finding already owns that blockage.
|
|
53
|
+
*/
|
|
54
|
+
export function unreachableCandidates(cands, visited, contained, beforeIndex = null) {
|
|
55
|
+
const reachedComposites = new Set();
|
|
56
|
+
const reachedRadioGroups = new Set();
|
|
57
|
+
for (const i of visited) {
|
|
58
|
+
const c = cands[i];
|
|
59
|
+
if (!c)
|
|
60
|
+
continue;
|
|
61
|
+
if (c.composite)
|
|
62
|
+
reachedComposites.add(c.composite);
|
|
63
|
+
if (c.type === 'radio' && c.name)
|
|
64
|
+
reachedRadioGroups.add(c.name);
|
|
65
|
+
}
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const c of cands) {
|
|
68
|
+
if (visited.has(c.i) || contained.has(c.i))
|
|
69
|
+
continue;
|
|
70
|
+
if (beforeIndex !== null && c.i >= beforeIndex)
|
|
71
|
+
continue;
|
|
72
|
+
if (c.type === 'radio' && reachedRadioGroups.has(c.name))
|
|
73
|
+
continue;
|
|
74
|
+
if (c.composite && reachedComposites.has(c.composite))
|
|
75
|
+
continue;
|
|
76
|
+
out.push(c);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
export function nextFocusableAfter(cands, after, visited) {
|
|
81
|
+
for (const c of cands) {
|
|
82
|
+
if (c.i > after && c.focusable && !visited.has(c.i))
|
|
83
|
+
return c.i;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/** True for a tabindex attribute that is a positive integer. */
|
|
88
|
+
export function isPositiveTabindex(value) {
|
|
89
|
+
if (!value)
|
|
90
|
+
return false;
|
|
91
|
+
const trimmed = value.trim().replace(/^\+/, '');
|
|
92
|
+
return /^\d+$/.test(trimmed) && Number(trimmed) > 0;
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okent/accessibility-scanner-keyboard-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Keyboard probe plugin for github/accessibility-scanner: focus traps, unreachable controls, removed focus indicators, and focus-order divergence, found by walking the real Tab order.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/owenpkent/accessibility-scanner-keyboard-plugin.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.build.json",
|
|
28
|
+
"prepack": "npm run build",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:unit": "vitest run test/walk.test.ts",
|
|
32
|
+
"test:e2e": "vitest run test/fixture.e2e.test.ts"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"accessibility",
|
|
36
|
+
"a11y",
|
|
37
|
+
"keyboard",
|
|
38
|
+
"wcag",
|
|
39
|
+
"playwright",
|
|
40
|
+
"accessibility-scanner"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"playwright": ">=1.40"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"playwright": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.0.0",
|
|
55
|
+
"playwright": "^1.50.0",
|
|
56
|
+
"typescript": "^5.5.0",
|
|
57
|
+
"vitest": "^3.0.0"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/owenpkent/accessibility-scanner-keyboard-plugin#readme",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/owenpkent/accessibility-scanner-keyboard-plugin/issues"
|
|
62
|
+
}
|
|
63
|
+
}
|