@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.
Files changed (47) hide show
  1. package/README.md +254 -0
  2. package/dist/index.d.ts +26 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +27 -0
  5. package/dist/lib/axe-bridge.d.ts +33 -0
  6. package/dist/lib/axe-bridge.d.ts.map +1 -0
  7. package/dist/lib/axe-bridge.js +106 -0
  8. package/dist/lib/resolve-all.d.ts +45 -0
  9. package/dist/lib/resolve-all.d.ts.map +1 -0
  10. package/dist/lib/resolve-all.js +117 -0
  11. package/dist/lib/resolver-pipeline.d.ts +51 -0
  12. package/dist/lib/resolver-pipeline.d.ts.map +1 -0
  13. package/dist/lib/resolver-pipeline.js +94 -0
  14. package/dist/lib/resolvers/aria-hidden-focus.d.ts +31 -0
  15. package/dist/lib/resolvers/aria-hidden-focus.d.ts.map +1 -0
  16. package/dist/lib/resolvers/aria-hidden-focus.js +148 -0
  17. package/dist/lib/resolvers/content-on-hover.d.ts +27 -0
  18. package/dist/lib/resolvers/content-on-hover.d.ts.map +1 -0
  19. package/dist/lib/resolvers/content-on-hover.js +230 -0
  20. package/dist/lib/resolvers/focus-indicator.d.ts +32 -0
  21. package/dist/lib/resolvers/focus-indicator.d.ts.map +1 -0
  22. package/dist/lib/resolvers/focus-indicator.js +188 -0
  23. package/dist/lib/resolvers/frame-tested.d.ts +31 -0
  24. package/dist/lib/resolvers/frame-tested.d.ts.map +1 -0
  25. package/dist/lib/resolvers/frame-tested.js +177 -0
  26. package/dist/lib/resolvers/identical-links-same-purpose.d.ts +35 -0
  27. package/dist/lib/resolvers/identical-links-same-purpose.d.ts.map +1 -0
  28. package/dist/lib/resolvers/identical-links-same-purpose.js +117 -0
  29. package/dist/lib/resolvers/link-in-text-block.d.ts +29 -0
  30. package/dist/lib/resolvers/link-in-text-block.d.ts.map +1 -0
  31. package/dist/lib/resolvers/link-in-text-block.js +141 -0
  32. package/dist/lib/resolvers/scrollable-region-focusable.d.ts +26 -0
  33. package/dist/lib/resolvers/scrollable-region-focusable.d.ts.map +1 -0
  34. package/dist/lib/resolvers/scrollable-region-focusable.js +139 -0
  35. package/dist/lib/resolvers/skip-link.d.ts +26 -0
  36. package/dist/lib/resolvers/skip-link.d.ts.map +1 -0
  37. package/dist/lib/resolvers/skip-link.js +140 -0
  38. package/dist/lib/resolvers/target-size.d.ts +25 -0
  39. package/dist/lib/resolvers/target-size.d.ts.map +1 -0
  40. package/dist/lib/resolvers/target-size.js +125 -0
  41. package/dist/lib/types.d.ts +227 -0
  42. package/dist/lib/types.d.ts.map +1 -0
  43. package/dist/lib/types.js +8 -0
  44. package/dist/lib/wcag-thresholds.d.ts +34 -0
  45. package/dist/lib/wcag-thresholds.d.ts.map +1 -0
  46. package/dist/lib/wcag-thresholds.js +55 -0
  47. 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"}