@okent/accessibility-scanner-keyboard-plugin 0.1.1 → 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 CHANGED
@@ -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.0"}]
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
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`);
@@ -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 evalScript(page, S.FOCUS, nxt);
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 evalScript(page, S.FOCUS, nxt);
185
- barriers.add(firstVisit.length);
186
- seq = [];
207
+ await stepTo(nxt);
187
208
  }
188
209
  notes.walkComplete = walkComplete;
189
210
  notes.trapsFound = traps.length;
@@ -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}";
@@ -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: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"]';
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@okent/accessibility-scanner-keyboard-plugin",
3
- "version": "0.1.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": {