@a11y-oracle/axe-bridge 1.0.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 +254 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/lib/axe-bridge.d.ts +33 -0
- package/dist/lib/axe-bridge.d.ts.map +1 -0
- package/dist/lib/axe-bridge.js +106 -0
- package/dist/lib/resolve-all.d.ts +45 -0
- package/dist/lib/resolve-all.d.ts.map +1 -0
- package/dist/lib/resolve-all.js +117 -0
- package/dist/lib/resolver-pipeline.d.ts +51 -0
- package/dist/lib/resolver-pipeline.d.ts.map +1 -0
- package/dist/lib/resolver-pipeline.js +94 -0
- package/dist/lib/resolvers/aria-hidden-focus.d.ts +31 -0
- package/dist/lib/resolvers/aria-hidden-focus.d.ts.map +1 -0
- package/dist/lib/resolvers/aria-hidden-focus.js +148 -0
- package/dist/lib/resolvers/content-on-hover.d.ts +27 -0
- package/dist/lib/resolvers/content-on-hover.d.ts.map +1 -0
- package/dist/lib/resolvers/content-on-hover.js +230 -0
- package/dist/lib/resolvers/focus-indicator.d.ts +32 -0
- package/dist/lib/resolvers/focus-indicator.d.ts.map +1 -0
- package/dist/lib/resolvers/focus-indicator.js +188 -0
- package/dist/lib/resolvers/frame-tested.d.ts +31 -0
- package/dist/lib/resolvers/frame-tested.d.ts.map +1 -0
- package/dist/lib/resolvers/frame-tested.js +177 -0
- package/dist/lib/resolvers/identical-links-same-purpose.d.ts +35 -0
- package/dist/lib/resolvers/identical-links-same-purpose.d.ts.map +1 -0
- package/dist/lib/resolvers/identical-links-same-purpose.js +117 -0
- package/dist/lib/resolvers/link-in-text-block.d.ts +29 -0
- package/dist/lib/resolvers/link-in-text-block.d.ts.map +1 -0
- package/dist/lib/resolvers/link-in-text-block.js +141 -0
- package/dist/lib/resolvers/scrollable-region-focusable.d.ts +26 -0
- package/dist/lib/resolvers/scrollable-region-focusable.d.ts.map +1 -0
- package/dist/lib/resolvers/scrollable-region-focusable.js +139 -0
- package/dist/lib/resolvers/skip-link.d.ts +26 -0
- package/dist/lib/resolvers/skip-link.d.ts.map +1 -0
- package/dist/lib/resolvers/skip-link.js +140 -0
- package/dist/lib/resolvers/target-size.d.ts +25 -0
- package/dist/lib/resolvers/target-size.d.ts.map +1 -0
- package/dist/lib/resolvers/target-size.js +125 -0
- package/dist/lib/types.d.ts +227 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +8 -0
- package/dist/lib/wcag-thresholds.d.ts +34 -0
- package/dist/lib/wcag-thresholds.d.ts.map +1 -0
- package/dist/lib/wcag-thresholds.js +55 -0
- package/package.json +53 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver-pipeline.d.ts","sourceRoot":"","sources":["../../src/lib/resolver-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE/D;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAEjD;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,UAAU,GAAG,UAAU,CAE5D;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAUhD;AAED,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,OAAO,EAAE,CAAC;IACrB,cAAc,EAAE,OAAO,EAAE,CAAC;IAC1B,eAAe,EAAE,OAAO,EAAE,CAAC;CAC5B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,eAAe,GACtB,IAAI,CAmCN;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,MAAM,GACb;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAIzC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module resolver-pipeline
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for axe-core incomplete rule resolvers.
|
|
5
|
+
* Provides the common clone → find-rule → analyze → promote → return
|
|
6
|
+
* pipeline that every resolver follows.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Extract the innermost CSS selector from an axe node's target.
|
|
10
|
+
*
|
|
11
|
+
* axe-core represents selectors as arrays where shadow DOM targets
|
|
12
|
+
* have multiple entries. We use the last (innermost) selector.
|
|
13
|
+
*/
|
|
14
|
+
export function getSelector(node) {
|
|
15
|
+
return node.target[node.target.length - 1] ?? '';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Deep-clone an AxeResults object to avoid mutating the original.
|
|
19
|
+
*/
|
|
20
|
+
export function cloneResults(results) {
|
|
21
|
+
return JSON.parse(JSON.stringify(results));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create a rule shell (all metadata, no nodes) from an existing rule.
|
|
25
|
+
*/
|
|
26
|
+
export function ruleShell(rule) {
|
|
27
|
+
return {
|
|
28
|
+
id: rule.id,
|
|
29
|
+
impact: rule.impact,
|
|
30
|
+
tags: [...rule.tags],
|
|
31
|
+
description: rule.description,
|
|
32
|
+
help: rule.help,
|
|
33
|
+
helpUrl: rule.helpUrl,
|
|
34
|
+
nodes: [],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Apply node promotions to a cloned AxeResults object.
|
|
39
|
+
*
|
|
40
|
+
* Moves nodes from the incomplete rule entry into the passes and
|
|
41
|
+
* violations arrays, then updates or removes the incomplete entry.
|
|
42
|
+
*
|
|
43
|
+
* @param clone - The cloned AxeResults to mutate.
|
|
44
|
+
* @param ruleIndex - Index of the rule in `clone.incomplete`.
|
|
45
|
+
* @param rule - The incomplete rule being resolved.
|
|
46
|
+
* @param result - Categorized nodes from the resolver's analysis.
|
|
47
|
+
*/
|
|
48
|
+
export function applyPromotions(clone, ruleIndex, rule, result) {
|
|
49
|
+
const ruleId = rule.id;
|
|
50
|
+
// Promote violations
|
|
51
|
+
if (result.violationNodes.length > 0) {
|
|
52
|
+
const existing = clone.violations.find((r) => r.id === ruleId);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.nodes.push(...result.violationNodes);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
clone.violations.push({
|
|
58
|
+
...ruleShell(rule),
|
|
59
|
+
nodes: result.violationNodes,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Promote passes
|
|
64
|
+
if (result.passNodes.length > 0) {
|
|
65
|
+
const existing = clone.passes.find((r) => r.id === ruleId);
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.nodes.push(...result.passNodes);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
clone.passes.push({
|
|
71
|
+
...ruleShell(rule),
|
|
72
|
+
nodes: result.passNodes,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Update or remove the incomplete entry
|
|
77
|
+
if (result.incompleteNodes.length > 0) {
|
|
78
|
+
rule.nodes = result.incompleteNodes;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
clone.incomplete.splice(ruleIndex, 1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Find a rule by ID in the incomplete array.
|
|
86
|
+
*
|
|
87
|
+
* @returns The rule index and rule object, or null if not found.
|
|
88
|
+
*/
|
|
89
|
+
export function findIncompleteRule(clone, ruleId) {
|
|
90
|
+
const index = clone.incomplete.findIndex((r) => r.id === ruleId);
|
|
91
|
+
if (index === -1)
|
|
92
|
+
return null;
|
|
93
|
+
return { index, rule: clone.incomplete[index] };
|
|
94
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module aria-hidden-focus
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `aria-hidden-focus` incomplete rule.
|
|
5
|
+
* Detects focusable elements inside `aria-hidden="true"` containers
|
|
6
|
+
* that are reachable via keyboard Tab navigation.
|
|
7
|
+
*
|
|
8
|
+
* Uses a single Tab traversal for ALL flagged nodes (not per-node)
|
|
9
|
+
* to avoid O(N*M) Tab presses.
|
|
10
|
+
*/
|
|
11
|
+
import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
|
|
12
|
+
import type { AxeResults, AriaHiddenFocusOptions } from '../types.js';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve incomplete `aria-hidden-focus` results.
|
|
15
|
+
*
|
|
16
|
+
* Focusable elements inside `aria-hidden="true"` containers should not
|
|
17
|
+
* be reachable via keyboard. This resolver:
|
|
18
|
+
* 1. Collects all flagged selectors into a lookup set
|
|
19
|
+
* 2. Resets focus to `<body>`
|
|
20
|
+
* 3. Tabs through the page up to `maxTabs` times
|
|
21
|
+
* 4. At each step, checks if the focused element matches a flagged selector
|
|
22
|
+
*
|
|
23
|
+
* Reachable via Tab → **Violation**. Not reached → **Pass**.
|
|
24
|
+
*
|
|
25
|
+
* @param cdp - CDP session for keyboard dispatch and DOM queries.
|
|
26
|
+
* @param axeResults - Raw axe-core results.
|
|
27
|
+
* @param options - Optional traversal limit.
|
|
28
|
+
* @returns Modified results with resolved findings.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveAriaHiddenFocus(cdp: CDPSessionLike, axeResults: AxeResults, options?: AriaHiddenFocusOptions): Promise<AxeResults>;
|
|
31
|
+
//# sourceMappingURL=aria-hidden-focus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aria-hidden-focus.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/aria-hidden-focus.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,UAAU,EAAW,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAa/E;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,UAAU,CAAC,CA8HrB"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module aria-hidden-focus
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `aria-hidden-focus` incomplete rule.
|
|
5
|
+
* Detects focusable elements inside `aria-hidden="true"` containers
|
|
6
|
+
* that are reachable via keyboard Tab navigation.
|
|
7
|
+
*
|
|
8
|
+
* Uses a single Tab traversal for ALL flagged nodes (not per-node)
|
|
9
|
+
* to avoid O(N*M) Tab presses.
|
|
10
|
+
*/
|
|
11
|
+
import { KeyboardEngine } from '@a11y-oracle/keyboard-engine';
|
|
12
|
+
import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
|
|
13
|
+
const RULE_ID = 'aria-hidden-focus';
|
|
14
|
+
/** Default maximum number of Tab presses before stopping traversal. */
|
|
15
|
+
const DEFAULT_MAX_TABS = 100;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve incomplete `aria-hidden-focus` results.
|
|
18
|
+
*
|
|
19
|
+
* Focusable elements inside `aria-hidden="true"` containers should not
|
|
20
|
+
* be reachable via keyboard. This resolver:
|
|
21
|
+
* 1. Collects all flagged selectors into a lookup set
|
|
22
|
+
* 2. Resets focus to `<body>`
|
|
23
|
+
* 3. Tabs through the page up to `maxTabs` times
|
|
24
|
+
* 4. At each step, checks if the focused element matches a flagged selector
|
|
25
|
+
*
|
|
26
|
+
* Reachable via Tab → **Violation**. Not reached → **Pass**.
|
|
27
|
+
*
|
|
28
|
+
* @param cdp - CDP session for keyboard dispatch and DOM queries.
|
|
29
|
+
* @param axeResults - Raw axe-core results.
|
|
30
|
+
* @param options - Optional traversal limit.
|
|
31
|
+
* @returns Modified results with resolved findings.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveAriaHiddenFocus(cdp, axeResults, options) {
|
|
34
|
+
const clone = cloneResults(axeResults);
|
|
35
|
+
const found = findIncompleteRule(clone, RULE_ID);
|
|
36
|
+
if (!found)
|
|
37
|
+
return clone;
|
|
38
|
+
const { index, rule } = found;
|
|
39
|
+
const maxTabs = options?.maxTabs ?? DEFAULT_MAX_TABS;
|
|
40
|
+
const keyboard = new KeyboardEngine(cdp);
|
|
41
|
+
// Build a map from selector to node for quick lookup
|
|
42
|
+
const selectorToNode = new Map();
|
|
43
|
+
const incompleteNodes = [];
|
|
44
|
+
for (const node of rule.nodes) {
|
|
45
|
+
const selector = getSelector(node);
|
|
46
|
+
if (!selector) {
|
|
47
|
+
incompleteNodes.push(node);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
selectorToNode.set(selector, node);
|
|
51
|
+
}
|
|
52
|
+
// Track which selectors were reached during traversal
|
|
53
|
+
const reachedSelectors = new Set();
|
|
54
|
+
// Reset focus to body
|
|
55
|
+
await cdp.send('Runtime.evaluate', {
|
|
56
|
+
expression: 'document.activeElement?.blur(); document.body.focus(); void 0;',
|
|
57
|
+
returnByValue: true,
|
|
58
|
+
});
|
|
59
|
+
// Tab through the page, checking at each step
|
|
60
|
+
let previousSelector = '';
|
|
61
|
+
for (let i = 0; i < maxTabs; i++) {
|
|
62
|
+
await keyboard.press('Tab');
|
|
63
|
+
// Get the currently focused element's unique selector
|
|
64
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
65
|
+
expression: `(() => {
|
|
66
|
+
const el = document.activeElement;
|
|
67
|
+
if (!el || el === document.body) return null;
|
|
68
|
+
// Build a selector that matches what axe-core generates
|
|
69
|
+
// Try ID first
|
|
70
|
+
if (el.id) return '#' + el.id;
|
|
71
|
+
// Build path
|
|
72
|
+
const path = [];
|
|
73
|
+
let current = el;
|
|
74
|
+
while (current && current !== document.body && current !== document.documentElement) {
|
|
75
|
+
let seg = current.tagName.toLowerCase();
|
|
76
|
+
if (current.id) {
|
|
77
|
+
seg = '#' + current.id;
|
|
78
|
+
path.unshift(seg);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const parent = current.parentElement;
|
|
82
|
+
if (parent) {
|
|
83
|
+
const siblings = Array.from(parent.children).filter(
|
|
84
|
+
c => c.tagName === current.tagName
|
|
85
|
+
);
|
|
86
|
+
if (siblings.length > 1) {
|
|
87
|
+
const idx = siblings.indexOf(current) + 1;
|
|
88
|
+
seg += ':nth-child(' + idx + ')';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
path.unshift(seg);
|
|
92
|
+
current = current.parentElement;
|
|
93
|
+
}
|
|
94
|
+
return path.join(' > ');
|
|
95
|
+
})()`,
|
|
96
|
+
returnByValue: true,
|
|
97
|
+
});
|
|
98
|
+
const focusedSelector = result.result.value;
|
|
99
|
+
// If we've looped back to body or same element, stop
|
|
100
|
+
if (!focusedSelector)
|
|
101
|
+
break;
|
|
102
|
+
if (focusedSelector === previousSelector)
|
|
103
|
+
break;
|
|
104
|
+
previousSelector = focusedSelector;
|
|
105
|
+
// Check if focused element matches any flagged selector
|
|
106
|
+
for (const [selector] of selectorToNode) {
|
|
107
|
+
if (reachedSelectors.has(selector))
|
|
108
|
+
continue;
|
|
109
|
+
// Check if the currently focused element matches this selector
|
|
110
|
+
const matchResult = await cdp.send('Runtime.evaluate', {
|
|
111
|
+
expression: `(() => {
|
|
112
|
+
const el = document.activeElement;
|
|
113
|
+
if (!el) return false;
|
|
114
|
+
try {
|
|
115
|
+
return el.matches(${JSON.stringify(selector)}) ||
|
|
116
|
+
el === document.querySelector(${JSON.stringify(selector)});
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
})()`,
|
|
119
|
+
returnByValue: true,
|
|
120
|
+
});
|
|
121
|
+
if (matchResult.result.value) {
|
|
122
|
+
reachedSelectors.add(selector);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Early exit if all selectors have been found
|
|
126
|
+
if (reachedSelectors.size === selectorToNode.size)
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
// Classify nodes
|
|
130
|
+
const passNodes = [];
|
|
131
|
+
const violationNodes = [];
|
|
132
|
+
for (const [selector, node] of selectorToNode) {
|
|
133
|
+
if (reachedSelectors.has(selector)) {
|
|
134
|
+
// Element inside aria-hidden is reachable via Tab → Violation
|
|
135
|
+
violationNodes.push(node);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Element is not reachable via Tab → Pass
|
|
139
|
+
passNodes.push(node);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
applyPromotions(clone, index, rule, {
|
|
143
|
+
passNodes,
|
|
144
|
+
violationNodes,
|
|
145
|
+
incompleteNodes,
|
|
146
|
+
});
|
|
147
|
+
return clone;
|
|
148
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module content-on-hover
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `content-on-hover` incomplete rule
|
|
5
|
+
* (WCAG 1.4.13 Content on Hover or Focus). Verifies that
|
|
6
|
+
* hover-triggered content is both hoverable and dismissible.
|
|
7
|
+
*/
|
|
8
|
+
import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
|
|
9
|
+
import type { AxeResults, ContentOnHoverOptions } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Resolve incomplete `content-on-hover` results.
|
|
12
|
+
*
|
|
13
|
+
* For each flagged trigger element:
|
|
14
|
+
* 1. Set up a MutationObserver to detect new content
|
|
15
|
+
* 2. Hover the trigger element via `Input.dispatchMouseEvent`
|
|
16
|
+
* 3. Wait for content to appear
|
|
17
|
+
* 4. **Hoverable test:** Move mouse to new content; if it disappears → Violation
|
|
18
|
+
* 5. **Dismissible test:** Press Escape; if content persists → Violation
|
|
19
|
+
* 6. Both pass → **Pass**
|
|
20
|
+
*
|
|
21
|
+
* @param cdp - CDP session for mouse/keyboard dispatch and DOM queries.
|
|
22
|
+
* @param axeResults - Raw axe-core results.
|
|
23
|
+
* @param options - Optional delay configuration.
|
|
24
|
+
* @returns Modified results with resolved findings.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveContentOnHover(cdp: CDPSessionLike, axeResults: AxeResults, options?: ContentOnHoverOptions): Promise<AxeResults>;
|
|
27
|
+
//# sourceMappingURL=content-on-hover.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-on-hover.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/content-on-hover.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,UAAU,EAAW,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAgB9E;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,qBAAqB,CACzC,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,UAAU,CAAC,CA2MrB"}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module content-on-hover
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `content-on-hover` incomplete rule
|
|
5
|
+
* (WCAG 1.4.13 Content on Hover or Focus). Verifies that
|
|
6
|
+
* hover-triggered content is both hoverable and dismissible.
|
|
7
|
+
*/
|
|
8
|
+
import { KeyboardEngine } from '@a11y-oracle/keyboard-engine';
|
|
9
|
+
import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
|
|
10
|
+
const RULE_ID = 'content-on-hover';
|
|
11
|
+
/** Default delay waiting for content to appear after hover. */
|
|
12
|
+
const DEFAULT_HOVER_DELAY = 300;
|
|
13
|
+
/** Default delay waiting for content to disappear after dismiss. */
|
|
14
|
+
const DEFAULT_DISMISS_DELAY = 200;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve incomplete `content-on-hover` results.
|
|
17
|
+
*
|
|
18
|
+
* For each flagged trigger element:
|
|
19
|
+
* 1. Set up a MutationObserver to detect new content
|
|
20
|
+
* 2. Hover the trigger element via `Input.dispatchMouseEvent`
|
|
21
|
+
* 3. Wait for content to appear
|
|
22
|
+
* 4. **Hoverable test:** Move mouse to new content; if it disappears → Violation
|
|
23
|
+
* 5. **Dismissible test:** Press Escape; if content persists → Violation
|
|
24
|
+
* 6. Both pass → **Pass**
|
|
25
|
+
*
|
|
26
|
+
* @param cdp - CDP session for mouse/keyboard dispatch and DOM queries.
|
|
27
|
+
* @param axeResults - Raw axe-core results.
|
|
28
|
+
* @param options - Optional delay configuration.
|
|
29
|
+
* @returns Modified results with resolved findings.
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveContentOnHover(cdp, axeResults, options) {
|
|
32
|
+
const clone = cloneResults(axeResults);
|
|
33
|
+
const found = findIncompleteRule(clone, RULE_ID);
|
|
34
|
+
if (!found)
|
|
35
|
+
return clone;
|
|
36
|
+
const { index, rule } = found;
|
|
37
|
+
const hoverDelay = options?.hoverDelay ?? DEFAULT_HOVER_DELAY;
|
|
38
|
+
const dismissDelay = options?.dismissDelay ?? DEFAULT_DISMISS_DELAY;
|
|
39
|
+
const keyboard = new KeyboardEngine(cdp);
|
|
40
|
+
const passNodes = [];
|
|
41
|
+
const violationNodes = [];
|
|
42
|
+
const incompleteNodes = [];
|
|
43
|
+
for (const node of rule.nodes) {
|
|
44
|
+
const selector = getSelector(node);
|
|
45
|
+
if (!selector) {
|
|
46
|
+
incompleteNodes.push(node);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// Set up MutationObserver and get trigger element position
|
|
50
|
+
const setupResult = await cdp.send('Runtime.evaluate', {
|
|
51
|
+
expression: `(() => {
|
|
52
|
+
const trigger = document.querySelector(${JSON.stringify(selector)});
|
|
53
|
+
if (!trigger) return null;
|
|
54
|
+
const rect = trigger.getBoundingClientRect();
|
|
55
|
+
|
|
56
|
+
// Set up observer to detect newly added elements
|
|
57
|
+
window.__a11yHoverNewContent = [];
|
|
58
|
+
window.__a11yHoverObserver = new MutationObserver((mutations) => {
|
|
59
|
+
for (const mutation of mutations) {
|
|
60
|
+
for (const added of mutation.addedNodes) {
|
|
61
|
+
if (added.nodeType === 1) {
|
|
62
|
+
window.__a11yHoverNewContent.push(added);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
window.__a11yHoverObserver.observe(document.body, {
|
|
68
|
+
childList: true,
|
|
69
|
+
subtree: true,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Record initial visibility state of potential tooltip/popup elements
|
|
73
|
+
// (some implementations toggle visibility rather than adding nodes)
|
|
74
|
+
const tooltips = document.querySelectorAll('[role="tooltip"], .tooltip, [data-tooltip]');
|
|
75
|
+
window.__a11yHoverInitialHidden = new Set();
|
|
76
|
+
tooltips.forEach(t => {
|
|
77
|
+
const cs = window.getComputedStyle(t);
|
|
78
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') {
|
|
79
|
+
window.__a11yHoverInitialHidden.add(t);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
x: Math.round(rect.x + rect.width / 2),
|
|
85
|
+
y: Math.round(rect.y + rect.height / 2),
|
|
86
|
+
};
|
|
87
|
+
})()`,
|
|
88
|
+
returnByValue: true,
|
|
89
|
+
});
|
|
90
|
+
if (!setupResult.result.value) {
|
|
91
|
+
incompleteNodes.push(node);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const { x: triggerX, y: triggerY } = setupResult.result.value;
|
|
95
|
+
// Hover the trigger element
|
|
96
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
97
|
+
type: 'mouseMoved',
|
|
98
|
+
x: triggerX,
|
|
99
|
+
y: triggerY,
|
|
100
|
+
});
|
|
101
|
+
// Wait for content to appear
|
|
102
|
+
await delay(hoverDelay);
|
|
103
|
+
// Check for new content
|
|
104
|
+
const contentResult = await cdp.send('Runtime.evaluate', {
|
|
105
|
+
expression: `(() => {
|
|
106
|
+
// Check for newly added nodes
|
|
107
|
+
let newContent = window.__a11yHoverNewContent || [];
|
|
108
|
+
|
|
109
|
+
// Also check for elements that became visible
|
|
110
|
+
const tooltips = document.querySelectorAll('[role="tooltip"], .tooltip, [data-tooltip]');
|
|
111
|
+
tooltips.forEach(t => {
|
|
112
|
+
if (window.__a11yHoverInitialHidden && window.__a11yHoverInitialHidden.has(t)) {
|
|
113
|
+
const cs = window.getComputedStyle(t);
|
|
114
|
+
if (cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0') {
|
|
115
|
+
newContent.push(t);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (newContent.length === 0) return null;
|
|
121
|
+
|
|
122
|
+
// Get position of the first new content element
|
|
123
|
+
const content = newContent[0];
|
|
124
|
+
const rect = content.getBoundingClientRect();
|
|
125
|
+
return {
|
|
126
|
+
x: Math.round(rect.x + rect.width / 2),
|
|
127
|
+
y: Math.round(rect.y + rect.height / 2),
|
|
128
|
+
visible: rect.width > 0 && rect.height > 0,
|
|
129
|
+
};
|
|
130
|
+
})()`,
|
|
131
|
+
returnByValue: true,
|
|
132
|
+
});
|
|
133
|
+
if (!contentResult.result.value || !contentResult.result.value.visible) {
|
|
134
|
+
// No hover content appeared — likely a false positive from axe-core
|
|
135
|
+
// or content appeared too slowly. Leave as incomplete.
|
|
136
|
+
await cleanup(cdp);
|
|
137
|
+
incompleteNodes.push(node);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const { x: contentX, y: contentY } = contentResult.result.value;
|
|
141
|
+
// Test 1: HOVERABLE — Move mouse to the new content
|
|
142
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
143
|
+
type: 'mouseMoved',
|
|
144
|
+
x: contentX,
|
|
145
|
+
y: contentY,
|
|
146
|
+
});
|
|
147
|
+
await delay(dismissDelay);
|
|
148
|
+
// Check if content is still visible
|
|
149
|
+
const hoverableResult = await cdp.send('Runtime.evaluate', {
|
|
150
|
+
expression: `(() => {
|
|
151
|
+
const newContent = window.__a11yHoverNewContent || [];
|
|
152
|
+
if (newContent.length === 0) return false;
|
|
153
|
+
const content = newContent[0];
|
|
154
|
+
// Check if it's still in the DOM and visible
|
|
155
|
+
if (!document.contains(content)) return false;
|
|
156
|
+
const cs = window.getComputedStyle(content);
|
|
157
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
158
|
+
const rect = content.getBoundingClientRect();
|
|
159
|
+
return rect.width > 0 && rect.height > 0;
|
|
160
|
+
})()`,
|
|
161
|
+
returnByValue: true,
|
|
162
|
+
});
|
|
163
|
+
const isHoverable = hoverableResult.result.value;
|
|
164
|
+
if (!isHoverable) {
|
|
165
|
+
// Content disappears when mouse moves to it → Violation
|
|
166
|
+
await cleanup(cdp);
|
|
167
|
+
violationNodes.push(node);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// Move mouse back to trigger to re-show content for dismiss test
|
|
171
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
172
|
+
type: 'mouseMoved',
|
|
173
|
+
x: triggerX,
|
|
174
|
+
y: triggerY,
|
|
175
|
+
});
|
|
176
|
+
await delay(hoverDelay);
|
|
177
|
+
// Test 2: DISMISSIBLE — Press Escape key
|
|
178
|
+
await keyboard.press('Escape');
|
|
179
|
+
await delay(dismissDelay);
|
|
180
|
+
// Check if content was dismissed
|
|
181
|
+
const dismissedResult = await cdp.send('Runtime.evaluate', {
|
|
182
|
+
expression: `(() => {
|
|
183
|
+
const newContent = window.__a11yHoverNewContent || [];
|
|
184
|
+
if (newContent.length === 0) return true;
|
|
185
|
+
const content = newContent[0];
|
|
186
|
+
if (!document.contains(content)) return true;
|
|
187
|
+
const cs = window.getComputedStyle(content);
|
|
188
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return true;
|
|
189
|
+
const rect = content.getBoundingClientRect();
|
|
190
|
+
return rect.width === 0 || rect.height === 0;
|
|
191
|
+
})()`,
|
|
192
|
+
returnByValue: true,
|
|
193
|
+
});
|
|
194
|
+
const isDismissible = dismissedResult.result.value;
|
|
195
|
+
await cleanup(cdp);
|
|
196
|
+
if (!isDismissible) {
|
|
197
|
+
// Content cannot be dismissed with Escape → Violation
|
|
198
|
+
violationNodes.push(node);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Content is both hoverable and dismissible → Pass
|
|
202
|
+
passNodes.push(node);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
applyPromotions(clone, index, rule, {
|
|
206
|
+
passNodes,
|
|
207
|
+
violationNodes,
|
|
208
|
+
incompleteNodes,
|
|
209
|
+
});
|
|
210
|
+
return clone;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Clean up the MutationObserver and temporary state.
|
|
214
|
+
*/
|
|
215
|
+
async function cleanup(cdp) {
|
|
216
|
+
await cdp.send('Runtime.evaluate', {
|
|
217
|
+
expression: `(() => {
|
|
218
|
+
if (window.__a11yHoverObserver) {
|
|
219
|
+
window.__a11yHoverObserver.disconnect();
|
|
220
|
+
delete window.__a11yHoverObserver;
|
|
221
|
+
}
|
|
222
|
+
delete window.__a11yHoverNewContent;
|
|
223
|
+
delete window.__a11yHoverInitialHidden;
|
|
224
|
+
})()`,
|
|
225
|
+
returnByValue: true,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function delay(ms) {
|
|
229
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
230
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module focus-indicator
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's incomplete focus-indicator-related rules
|
|
5
|
+
* (WCAG 2.4.7 Focus Visible). Takes before/after screenshots of
|
|
6
|
+
* elements in resting vs focused states and pixel-diffs them.
|
|
7
|
+
*
|
|
8
|
+
* If the screenshots are identical → no visible focus indicator → Violation.
|
|
9
|
+
* If they differ → focus indicator is present → Pass.
|
|
10
|
+
*/
|
|
11
|
+
import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
|
|
12
|
+
import type { AxeResults, FocusIndicatorOptions } from '../types.js';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve incomplete focus-indicator results.
|
|
15
|
+
*
|
|
16
|
+
* For each flagged element:
|
|
17
|
+
* 1. Capture a clipped screenshot of the element in resting state
|
|
18
|
+
* 2. Focus the element via `element.focus()`
|
|
19
|
+
* 3. Wait for CSS transitions to settle
|
|
20
|
+
* 4. Capture a second screenshot
|
|
21
|
+
* 5. Pixel-diff the two images
|
|
22
|
+
*
|
|
23
|
+
* Different → **Pass** (focus indicator visible).
|
|
24
|
+
* Identical → **Violation** (no visible focus change).
|
|
25
|
+
*
|
|
26
|
+
* @param cdp - CDP session for screenshots and focus dispatch.
|
|
27
|
+
* @param axeResults - Raw axe-core results.
|
|
28
|
+
* @param options - Optional delay and threshold configuration.
|
|
29
|
+
* @returns Modified results with resolved findings.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveFocusIndicator(cdp: CDPSessionLike, axeResults: AxeResults, options?: FocusIndicatorOptions): Promise<AxeResults>;
|
|
32
|
+
//# sourceMappingURL=focus-indicator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus-indicator.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/focus-indicator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,UAAU,EAAW,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAmB9E;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,qBAAqB,CACzC,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,UAAU,CAAC,CAkHrB"}
|