@okent/accessibility-scanner-keyboard-plugin 0.1.0 → 0.1.2
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 +5 -5
- package/dist/index.js +5 -0
- package/dist/src/probe.d.ts +2 -0
- package/dist/src/probe.js +28 -7
- package/dist/src/scripts.d.ts +1 -0
- package/dist/src/scripts.js +9 -5
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A plugin for [GitHub's accessibility scanner](https://github.com/github/accessibility-scanner)
|
|
4
4
|
that walks the real Tab order of each page and reports the keyboard failures a
|
|
5
5
|
rule engine inspecting the DOM at rest cannot see. It is a TypeScript port of
|
|
6
|
-
the keyboard probe in
|
|
6
|
+
the keyboard probe in accessibility-tooling, an ACR toolkit, with the same rules, the
|
|
7
7
|
same thresholds, and the same refusal to treat a clean walk as a pass.
|
|
8
8
|
|
|
9
9
|
| Rule | Criterion | Asserts |
|
|
@@ -44,7 +44,7 @@ Once the package is on the scanner's npm allowlist:
|
|
|
44
44
|
- uses: github/accessibility-scanner@v3
|
|
45
45
|
with:
|
|
46
46
|
scans: |
|
|
47
|
-
["axe", {"name": "keyboard-scan", "package": "@okent/accessibility-scanner-keyboard-plugin", "version": "0.1.
|
|
47
|
+
["axe", {"name": "keyboard-scan", "package": "@okent/accessibility-scanner-keyboard-plugin", "version": "0.1.2"}]
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
### As a local plugin
|
|
@@ -110,12 +110,12 @@ into one summary finding.
|
|
|
110
110
|
npm install
|
|
111
111
|
npx playwright install chromium
|
|
112
112
|
npm test # unit tests for the walk analysis, plus an end-to-end run
|
|
113
|
-
# against
|
|
113
|
+
# against test/fixtures/demo.html
|
|
114
114
|
npm run build
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
The end-to-end test asserts that every keyboard defect planted in the
|
|
118
|
-
|
|
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
119
|
fixture's header comment lists what is planted.
|
|
120
120
|
|
|
121
121
|
## License
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,11 @@ export default async function keyboardScan({ page, addFinding }) {
|
|
|
55
55
|
console.warn(`[keyboard-scan] ${url}: ${notes.framesNotWalked} iframe(s) NOT walked; ` +
|
|
56
56
|
`Tab order, traps and focus indicators inside them were not checked`);
|
|
57
57
|
}
|
|
58
|
+
if (notes.modalScope) {
|
|
59
|
+
// A narrowed scan must never be silent: the rest of the page was not walked.
|
|
60
|
+
console.warn(`[keyboard-scan] ${url}: walk scoped to modal \`${notes.modalScope}\`; ` +
|
|
61
|
+
`everything outside it was NOT checked`);
|
|
62
|
+
}
|
|
58
63
|
if (notes.dismissed) {
|
|
59
64
|
console.warn(`[keyboard-scan] ${url}: Escape closed a container mid-walk, so the walk stopped there; ` +
|
|
60
65
|
`reachability and focus visibility were not judged on this page`);
|
package/dist/src/probe.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type ProbeNotes = {
|
|
|
18
18
|
walkComplete: boolean;
|
|
19
19
|
trapsFound: number;
|
|
20
20
|
dismissed: boolean;
|
|
21
|
+
/** Selector of the modal the walk was scoped to, or null when it covered the page. */
|
|
22
|
+
modalScope: string | null;
|
|
21
23
|
};
|
|
22
24
|
/**
|
|
23
25
|
* Walk the real Tab order of the page as it currently stands and report what
|
package/dist/src/probe.js
CHANGED
|
@@ -61,9 +61,14 @@ export async function probePage(page, cfg) {
|
|
|
61
61
|
walkComplete: false,
|
|
62
62
|
trapsFound: 0,
|
|
63
63
|
dismissed: false,
|
|
64
|
+
modalScope: await evalScript(page, S.SCOPE),
|
|
64
65
|
};
|
|
65
|
-
if (!cands.length)
|
|
66
|
+
if (!cands.length) {
|
|
67
|
+
// Nothing interactive to walk is a complete walk with nothing to judge,
|
|
68
|
+
// not an interrupted one.
|
|
69
|
+
notes.walkComplete = true;
|
|
66
70
|
return { findings, notes };
|
|
71
|
+
}
|
|
67
72
|
const n = cands.length;
|
|
68
73
|
await evalScript(page, S.BLUR);
|
|
69
74
|
const visited = new Set();
|
|
@@ -73,6 +78,7 @@ export async function probePage(page, cfg) {
|
|
|
73
78
|
const shots = new Map();
|
|
74
79
|
const traps = [];
|
|
75
80
|
const escapedCycles = [];
|
|
81
|
+
const trapCycles = [];
|
|
76
82
|
let dismissed = false;
|
|
77
83
|
let walkComplete = false;
|
|
78
84
|
let stray = 0;
|
|
@@ -90,6 +96,15 @@ export async function probePage(page, cfg) {
|
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
};
|
|
99
|
+
// Move focus past an iframe or a trap and record the landing element as
|
|
100
|
+
// visited. Without this the next Tab leaves `nxt` without it ever being
|
|
101
|
+
// counted, so it is reported unreachable and skips its focus indicator check.
|
|
102
|
+
const stepTo = async (nxt) => {
|
|
103
|
+
await evalScript(page, S.FOCUS, nxt);
|
|
104
|
+
barriers.add(firstVisit.length);
|
|
105
|
+
await noteVisit(nxt);
|
|
106
|
+
seq = [];
|
|
107
|
+
};
|
|
93
108
|
while (step < maxSteps) {
|
|
94
109
|
step++;
|
|
95
110
|
await page.keyboard.press('Tab');
|
|
@@ -124,6 +139,15 @@ export async function probePage(page, cfg) {
|
|
|
124
139
|
const cyc = detectCycle(seq);
|
|
125
140
|
if (!cyc || escapedCycles.some(e => sameMembers(e, cyc)))
|
|
126
141
|
continue;
|
|
142
|
+
if (trapCycles.some(e => sameMembers(e, cyc))) {
|
|
143
|
+
// A second pass has walked back into a trap already reported. It is the
|
|
144
|
+
// same defect, so step past it rather than report it twice.
|
|
145
|
+
const again = nextFocusableAfter(cands, Math.max(...cyc), visited);
|
|
146
|
+
if (again === null)
|
|
147
|
+
break;
|
|
148
|
+
await stepTo(again);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
127
151
|
if (new Set(cyc).size >= n) {
|
|
128
152
|
// The whole page cycling is wrap behaviour, not a trap.
|
|
129
153
|
walkComplete = true;
|
|
@@ -137,9 +161,7 @@ export async function probePage(page, cfg) {
|
|
|
137
161
|
walkComplete = true;
|
|
138
162
|
break;
|
|
139
163
|
}
|
|
140
|
-
await
|
|
141
|
-
barriers.add(firstVisit.length);
|
|
142
|
-
seq = [];
|
|
164
|
+
await stepTo(nxt);
|
|
143
165
|
continue;
|
|
144
166
|
}
|
|
145
167
|
// Candidate trap. The one exit a probe can honestly press is Escape: if
|
|
@@ -166,6 +188,7 @@ export async function probePage(page, cfg) {
|
|
|
166
188
|
continue;
|
|
167
189
|
}
|
|
168
190
|
traps.push(cyc);
|
|
191
|
+
trapCycles.push(cyc);
|
|
169
192
|
const anc = await evalScript(page, S.COMMON_ANCESTOR, cyc);
|
|
170
193
|
const members = cyc.map(i => `\`${cands[i].selector}\``).join(', ');
|
|
171
194
|
findings.push({
|
|
@@ -181,9 +204,7 @@ export async function probePage(page, cfg) {
|
|
|
181
204
|
const nxt = nextFocusableAfter(cands, Math.max(...cyc), visited);
|
|
182
205
|
if (nxt === null)
|
|
183
206
|
break;
|
|
184
|
-
await
|
|
185
|
-
barriers.add(firstVisit.length);
|
|
186
|
-
seq = [];
|
|
207
|
+
await stepTo(nxt);
|
|
187
208
|
}
|
|
188
209
|
notes.walkComplete = walkComplete;
|
|
189
210
|
notes.trapsFound = traps.length;
|
package/dist/src/scripts.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const ENUMERATE: string;
|
|
2
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
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 SCOPE = "() => (window.__kbProbe && window.__kbProbe.scope) || null";
|
|
4
5
|
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
6
|
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
7
|
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}";
|
package/dist/src/scripts.js
CHANGED
|
@@ -107,10 +107,12 @@ const IFRAME_COUNT = `
|
|
|
107
107
|
`;
|
|
108
108
|
export const ENUMERATE = `() => {
|
|
109
109
|
${QUERY_ALL}${COMPOSED_ANCESTRY}${COMPOSED_SELECTOR}${SHADOW_ROOT_COUNT}
|
|
110
|
-
// dialog
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
110
|
+
// Only a dialog that is actually modal narrows the walk: a <dialog> opened
|
|
111
|
+
// with showModal() (:modal), or one that declares aria-modal="true". A
|
|
112
|
+
// visible role="dialog" alone is routinely a cookie banner or chat widget
|
|
113
|
+
// sitting beside the page; scoping to it would silently drop the rest of
|
|
114
|
+
// the page from the scan. The first visible match wins.
|
|
115
|
+
const MODAL_SEL = 'dialog:modal, [role="dialog"][aria-modal="true"], [role="alertdialog"][aria-modal="true"]';
|
|
114
116
|
let modal = null;
|
|
115
117
|
for (const el of queryAllComposed(document.body, MODAL_SEL)) {
|
|
116
118
|
const mcs = getComputedStyle(el);
|
|
@@ -146,7 +148,8 @@ ${QUERY_ALL}${COMPOSED_ANCESTRY}${COMPOSED_SELECTOR}${SHADOW_ROOT_COUNT}
|
|
|
146
148
|
if (composedClosest(el, '[inert]')) continue;
|
|
147
149
|
els.push(el);
|
|
148
150
|
}
|
|
149
|
-
window.__kbProbe = { els: els, path: composedSelectorPath
|
|
151
|
+
window.__kbProbe = { els: els, path: composedSelectorPath,
|
|
152
|
+
scope: modal ? composedSelectorPath(modal) : null };
|
|
150
153
|
const TEXTLIKE = ['button', 'submit', 'reset', 'checkbox', 'radio', 'range',
|
|
151
154
|
'color', 'file', 'image'];
|
|
152
155
|
return els.map((el, i) => {
|
|
@@ -176,6 +179,7 @@ export const FRAME_COUNT = `() => {
|
|
|
176
179
|
${QUERY_ALL}${IFRAME_COUNT}
|
|
177
180
|
return countFrames(document.body);
|
|
178
181
|
}`;
|
|
182
|
+
export const SCOPE = `() => (window.__kbProbe && window.__kbProbe.scope) || null`;
|
|
179
183
|
export const ACTIVE = `() => {
|
|
180
184
|
${DEEP_ACTIVE_ELEMENT}
|
|
181
185
|
const p = window.__kbProbe || { els: [] };
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okent/accessibility-scanner-keyboard-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/owenpkent/accessibility-
|
|
9
|
-
"directory": "plugins/keyboard-scan"
|
|
8
|
+
"url": "git+https://github.com/owenpkent/accessibility-scanner-keyboard-plugin.git"
|
|
10
9
|
},
|
|
11
10
|
"type": "module",
|
|
12
11
|
"main": "./dist/index.js",
|
|
@@ -56,5 +55,9 @@
|
|
|
56
55
|
"playwright": "^1.50.0",
|
|
57
56
|
"typescript": "^5.5.0",
|
|
58
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"
|
|
59
62
|
}
|
|
60
63
|
}
|