@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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module link-in-text-block
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `link-in-text-block` incomplete rule
|
|
5
|
+
* (WCAG 1.4.1 Use of Color). Checks the DEFAULT/resting state of
|
|
6
|
+
* inline links for non-color visual differentiation.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: Hover/focus states are NOT checked. If a link only
|
|
9
|
+
* shows differentiation on hover or focus, that is a **Violation**.
|
|
10
|
+
* The link must be visually distinct in its resting state.
|
|
11
|
+
*/
|
|
12
|
+
import { parseColor, contrastRatio } from '@a11y-oracle/focus-analyzer';
|
|
13
|
+
import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
|
|
14
|
+
const RULE_ID = 'link-in-text-block';
|
|
15
|
+
/** Default minimum contrast ratio between link color and surrounding text. */
|
|
16
|
+
const DEFAULT_LINK_TEXT_CONTRAST = 3.0;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve incomplete `link-in-text-block` results.
|
|
19
|
+
*
|
|
20
|
+
* Checks the **default/resting state** of inline links for:
|
|
21
|
+
* 1. Non-color visual indicators (underline, border-bottom, font-weight diff)
|
|
22
|
+
* 2. Sufficient color contrast (≥ 3:1) between link and surrounding text
|
|
23
|
+
*
|
|
24
|
+
* If neither is present → Violation (indistinguishable from surrounding text).
|
|
25
|
+
*
|
|
26
|
+
* @param cdp - CDP session for querying computed styles.
|
|
27
|
+
* @param axeResults - Raw axe-core results.
|
|
28
|
+
* @param options - Optional threshold override.
|
|
29
|
+
* @returns Modified results with resolved findings.
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveLinkInTextBlock(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 threshold = options?.linkTextContrastThreshold ?? DEFAULT_LINK_TEXT_CONTRAST;
|
|
38
|
+
const passNodes = [];
|
|
39
|
+
const violationNodes = [];
|
|
40
|
+
const incompleteNodes = [];
|
|
41
|
+
for (const node of rule.nodes) {
|
|
42
|
+
const selector = getSelector(node);
|
|
43
|
+
if (!selector) {
|
|
44
|
+
incompleteNodes.push(node);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Query computed styles for link and its parent in DEFAULT state
|
|
48
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
49
|
+
expression: `(() => {
|
|
50
|
+
const link = document.querySelector(${JSON.stringify(selector)});
|
|
51
|
+
if (!link) return null;
|
|
52
|
+
const parent = link.parentElement;
|
|
53
|
+
if (!parent) return null;
|
|
54
|
+
const linkCS = window.getComputedStyle(link);
|
|
55
|
+
const parentCS = window.getComputedStyle(parent);
|
|
56
|
+
return {
|
|
57
|
+
link: {
|
|
58
|
+
textDecorationLine: linkCS.textDecorationLine,
|
|
59
|
+
borderBottomWidth: linkCS.borderBottomWidth,
|
|
60
|
+
borderBottomStyle: linkCS.borderBottomStyle,
|
|
61
|
+
fontWeight: linkCS.fontWeight,
|
|
62
|
+
color: linkCS.color,
|
|
63
|
+
},
|
|
64
|
+
parent: {
|
|
65
|
+
fontWeight: parentCS.fontWeight,
|
|
66
|
+
color: parentCS.color,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
})()`,
|
|
70
|
+
returnByValue: true,
|
|
71
|
+
});
|
|
72
|
+
const data = result.result.value;
|
|
73
|
+
if (!data) {
|
|
74
|
+
incompleteNodes.push(node);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Check 1: Non-color visual indicator in default state
|
|
78
|
+
if (hasNonColorIndicator(data)) {
|
|
79
|
+
passNodes.push(node);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Check 2: Sufficient contrast between link color and surrounding text
|
|
83
|
+
const linkColor = parseColor(data.link.color);
|
|
84
|
+
const parentColor = parseColor(data.parent.color);
|
|
85
|
+
if (linkColor && parentColor) {
|
|
86
|
+
const cr = contrastRatio(linkColor, parentColor);
|
|
87
|
+
if (cr >= threshold) {
|
|
88
|
+
passNodes.push(node);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// No non-color indicator and insufficient contrast → Violation
|
|
93
|
+
violationNodes.push(node);
|
|
94
|
+
}
|
|
95
|
+
applyPromotions(clone, index, rule, {
|
|
96
|
+
passNodes,
|
|
97
|
+
violationNodes,
|
|
98
|
+
incompleteNodes,
|
|
99
|
+
});
|
|
100
|
+
return clone;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check if the link has a non-color visual indicator in its default state.
|
|
104
|
+
*/
|
|
105
|
+
function hasNonColorIndicator(data) {
|
|
106
|
+
// Underline present
|
|
107
|
+
if (data.link.textDecorationLine.includes('underline')) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
// Border-bottom present (visible border)
|
|
111
|
+
const borderWidth = parseFloat(data.link.borderBottomWidth);
|
|
112
|
+
if (!isNaN(borderWidth) &&
|
|
113
|
+
borderWidth > 0 &&
|
|
114
|
+
data.link.borderBottomStyle !== 'none') {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
// Font-weight difference between link and parent
|
|
118
|
+
const linkWeight = parseFontWeight(data.link.fontWeight);
|
|
119
|
+
const parentWeight = parseFontWeight(data.parent.fontWeight);
|
|
120
|
+
if (linkWeight !== parentWeight) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse CSS font-weight to a numeric value.
|
|
127
|
+
*/
|
|
128
|
+
function parseFontWeight(weight) {
|
|
129
|
+
switch (weight) {
|
|
130
|
+
case 'normal':
|
|
131
|
+
return 400;
|
|
132
|
+
case 'bold':
|
|
133
|
+
return 700;
|
|
134
|
+
case 'lighter':
|
|
135
|
+
return 100;
|
|
136
|
+
case 'bolder':
|
|
137
|
+
return 900;
|
|
138
|
+
default:
|
|
139
|
+
return parseInt(weight, 10) || 400;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module scrollable-region-focusable
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `scrollable-region-focusable` incomplete rule
|
|
5
|
+
* (WCAG 2.1.1 Keyboard). Verifies that scrollable containers are
|
|
6
|
+
* keyboard-accessible, either via tabindex or focusable children.
|
|
7
|
+
*/
|
|
8
|
+
import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
|
|
9
|
+
import type { AxeResults, ScrollableRegionOptions } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Resolve incomplete `scrollable-region-focusable` results.
|
|
12
|
+
*
|
|
13
|
+
* For each flagged scrollable container:
|
|
14
|
+
* 1. Check if actually scrollable (scrollHeight > clientHeight).
|
|
15
|
+
* If not → **Pass** (false positive).
|
|
16
|
+
* 2. Check for `tabindex >= 0` on container → **Pass**.
|
|
17
|
+
* 3. Check for visible focusable children. If none → **Violation**.
|
|
18
|
+
* 4. Focus last focusable child, check if scroll position changed → **Pass**.
|
|
19
|
+
*
|
|
20
|
+
* @param cdp - CDP session for querying DOM properties.
|
|
21
|
+
* @param axeResults - Raw axe-core results.
|
|
22
|
+
* @param options - Optional configuration.
|
|
23
|
+
* @returns Modified results with resolved findings.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveScrollableRegionFocusable(cdp: CDPSessionLike, axeResults: AxeResults, options?: ScrollableRegionOptions): Promise<AxeResults>;
|
|
26
|
+
//# sourceMappingURL=scrollable-region-focusable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scrollable-region-focusable.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/scrollable-region-focusable.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAW,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAoBhF;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,gCAAgC,CACpD,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC,UAAU,CAAC,CAgHrB"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module scrollable-region-focusable
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `scrollable-region-focusable` incomplete rule
|
|
5
|
+
* (WCAG 2.1.1 Keyboard). Verifies that scrollable containers are
|
|
6
|
+
* keyboard-accessible, either via tabindex or focusable children.
|
|
7
|
+
*/
|
|
8
|
+
import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
|
|
9
|
+
const RULE_ID = 'scrollable-region-focusable';
|
|
10
|
+
/** CSS selector for focusable elements within a container. */
|
|
11
|
+
const FOCUSABLE_SELECTOR = [
|
|
12
|
+
'a[href]',
|
|
13
|
+
'button:not([disabled])',
|
|
14
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
15
|
+
'select:not([disabled])',
|
|
16
|
+
'textarea:not([disabled])',
|
|
17
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
18
|
+
].join(', ');
|
|
19
|
+
/**
|
|
20
|
+
* Resolve incomplete `scrollable-region-focusable` results.
|
|
21
|
+
*
|
|
22
|
+
* For each flagged scrollable container:
|
|
23
|
+
* 1. Check if actually scrollable (scrollHeight > clientHeight).
|
|
24
|
+
* If not → **Pass** (false positive).
|
|
25
|
+
* 2. Check for `tabindex >= 0` on container → **Pass**.
|
|
26
|
+
* 3. Check for visible focusable children. If none → **Violation**.
|
|
27
|
+
* 4. Focus last focusable child, check if scroll position changed → **Pass**.
|
|
28
|
+
*
|
|
29
|
+
* @param cdp - CDP session for querying DOM properties.
|
|
30
|
+
* @param axeResults - Raw axe-core results.
|
|
31
|
+
* @param options - Optional configuration.
|
|
32
|
+
* @returns Modified results with resolved findings.
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveScrollableRegionFocusable(cdp, axeResults, options) {
|
|
35
|
+
const clone = cloneResults(axeResults);
|
|
36
|
+
const found = findIncompleteRule(clone, RULE_ID);
|
|
37
|
+
if (!found)
|
|
38
|
+
return clone;
|
|
39
|
+
const { index, rule } = found;
|
|
40
|
+
const maxChildren = options?.maxChildren ?? 50;
|
|
41
|
+
const passNodes = [];
|
|
42
|
+
const violationNodes = [];
|
|
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
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
51
|
+
expression: `(() => {
|
|
52
|
+
const container = document.querySelector(${JSON.stringify(selector)});
|
|
53
|
+
if (!container) return null;
|
|
54
|
+
|
|
55
|
+
// Check 1: Is it actually scrollable?
|
|
56
|
+
const isScrollableV = container.scrollHeight > container.clientHeight;
|
|
57
|
+
const isScrollableH = container.scrollWidth > container.clientWidth;
|
|
58
|
+
if (!isScrollableV && !isScrollableH) {
|
|
59
|
+
return { category: 'pass', reason: 'not-scrollable' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check 2: Does the container itself have tabindex?
|
|
63
|
+
if (container.tabIndex >= 0) {
|
|
64
|
+
return { category: 'pass', reason: 'has-tabindex' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check 3: Find visible focusable children
|
|
68
|
+
const focusable = container.querySelectorAll(${JSON.stringify(FOCUSABLE_SELECTOR)});
|
|
69
|
+
const visible = [];
|
|
70
|
+
for (let i = 0; i < Math.min(focusable.length, ${maxChildren}); i++) {
|
|
71
|
+
const el = focusable[i];
|
|
72
|
+
const cs = window.getComputedStyle(el);
|
|
73
|
+
if (cs.display !== 'none' && cs.visibility !== 'hidden' && el.offsetParent !== null) {
|
|
74
|
+
visible.push(el);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (visible.length === 0) {
|
|
79
|
+
return { category: 'violation', reason: 'no-focusable-children' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check 4: Focus last child and see if container scrolls
|
|
83
|
+
const origScrollTop = container.scrollTop;
|
|
84
|
+
const origScrollLeft = container.scrollLeft;
|
|
85
|
+
const lastChild = visible[visible.length - 1];
|
|
86
|
+
lastChild.focus();
|
|
87
|
+
const scrolled =
|
|
88
|
+
container.scrollTop !== origScrollTop ||
|
|
89
|
+
container.scrollLeft !== origScrollLeft;
|
|
90
|
+
|
|
91
|
+
// Restore state
|
|
92
|
+
container.scrollTop = origScrollTop;
|
|
93
|
+
container.scrollLeft = origScrollLeft;
|
|
94
|
+
lastChild.blur();
|
|
95
|
+
|
|
96
|
+
if (scrolled) {
|
|
97
|
+
return { category: 'pass', reason: 'scroll-reached' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Last child was already visible without scrolling — check
|
|
101
|
+
// if there's content beyond what's reachable by focusable children
|
|
102
|
+
const lastRect = lastChild.getBoundingClientRect();
|
|
103
|
+
const containerRect = container.getBoundingClientRect();
|
|
104
|
+
const contentBottom = container.scrollHeight;
|
|
105
|
+
const lastChildBottom = lastRect.bottom - containerRect.top + container.scrollTop;
|
|
106
|
+
|
|
107
|
+
if (lastChildBottom >= contentBottom - 5) {
|
|
108
|
+
// Focusable children reach the bottom of content
|
|
109
|
+
return { category: 'pass', reason: 'children-cover-content' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// There's content below the last focusable child that is unreachable
|
|
113
|
+
return { category: 'violation', reason: 'unreachable-content' };
|
|
114
|
+
})()`,
|
|
115
|
+
returnByValue: true,
|
|
116
|
+
});
|
|
117
|
+
const data = result.result.value;
|
|
118
|
+
if (!data) {
|
|
119
|
+
incompleteNodes.push(node);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
switch (data.category) {
|
|
123
|
+
case 'pass':
|
|
124
|
+
passNodes.push(node);
|
|
125
|
+
break;
|
|
126
|
+
case 'violation':
|
|
127
|
+
violationNodes.push(node);
|
|
128
|
+
break;
|
|
129
|
+
default:
|
|
130
|
+
incompleteNodes.push(node);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
applyPromotions(clone, index, rule, {
|
|
134
|
+
passNodes,
|
|
135
|
+
violationNodes,
|
|
136
|
+
incompleteNodes,
|
|
137
|
+
});
|
|
138
|
+
return clone;
|
|
139
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module skip-link
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `skip-link` incomplete rule
|
|
5
|
+
* (WCAG 2.4.1 Bypass Blocks). Verifies that skip links become
|
|
6
|
+
* visible when they receive keyboard focus.
|
|
7
|
+
*/
|
|
8
|
+
import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
|
|
9
|
+
import type { AxeResults, SkipLinkOptions } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Resolve incomplete `skip-link` results.
|
|
12
|
+
*
|
|
13
|
+
* Skip links are typically visually hidden until focused. This resolver:
|
|
14
|
+
* 1. Resets focus to the document body
|
|
15
|
+
* 2. Dispatches a native Tab keystroke to focus the skip link
|
|
16
|
+
* 3. Checks if the element becomes visible (bounding box, opacity, clip, position)
|
|
17
|
+
*
|
|
18
|
+
* Visible on focus → **Pass**. Hidden on focus → **Violation**.
|
|
19
|
+
*
|
|
20
|
+
* @param cdp - CDP session for keyboard dispatch and style queries.
|
|
21
|
+
* @param axeResults - Raw axe-core results.
|
|
22
|
+
* @param options - Optional delay configuration.
|
|
23
|
+
* @returns Modified results with resolved findings.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveSkipLink(cdp: CDPSessionLike, axeResults: AxeResults, options?: SkipLinkOptions): Promise<AxeResults>;
|
|
26
|
+
//# sourceMappingURL=skip-link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skip-link.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/skip-link.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAW,eAAe,EAAE,MAAM,aAAa,CAAC;AAaxE;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,eAAe,GACxB,OAAO,CAAC,UAAU,CAAC,CAuFrB"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module skip-link
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `skip-link` incomplete rule
|
|
5
|
+
* (WCAG 2.4.1 Bypass Blocks). Verifies that skip links become
|
|
6
|
+
* visible when they receive keyboard focus.
|
|
7
|
+
*/
|
|
8
|
+
import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
|
|
9
|
+
const RULE_ID = 'skip-link';
|
|
10
|
+
/** Default delay after focus for CSS transitions to settle. */
|
|
11
|
+
const DEFAULT_FOCUS_SETTLE_DELAY = 100;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve incomplete `skip-link` results.
|
|
14
|
+
*
|
|
15
|
+
* Skip links are typically visually hidden until focused. This resolver:
|
|
16
|
+
* 1. Resets focus to the document body
|
|
17
|
+
* 2. Dispatches a native Tab keystroke to focus the skip link
|
|
18
|
+
* 3. Checks if the element becomes visible (bounding box, opacity, clip, position)
|
|
19
|
+
*
|
|
20
|
+
* Visible on focus → **Pass**. Hidden on focus → **Violation**.
|
|
21
|
+
*
|
|
22
|
+
* @param cdp - CDP session for keyboard dispatch and style queries.
|
|
23
|
+
* @param axeResults - Raw axe-core results.
|
|
24
|
+
* @param options - Optional delay configuration.
|
|
25
|
+
* @returns Modified results with resolved findings.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveSkipLink(cdp, axeResults, options) {
|
|
28
|
+
const clone = cloneResults(axeResults);
|
|
29
|
+
const found = findIncompleteRule(clone, RULE_ID);
|
|
30
|
+
if (!found)
|
|
31
|
+
return clone;
|
|
32
|
+
const { index, rule } = found;
|
|
33
|
+
const settleDelay = options?.focusSettleDelay ?? DEFAULT_FOCUS_SETTLE_DELAY;
|
|
34
|
+
const passNodes = [];
|
|
35
|
+
const violationNodes = [];
|
|
36
|
+
const incompleteNodes = [];
|
|
37
|
+
for (const node of rule.nodes) {
|
|
38
|
+
const selector = getSelector(node);
|
|
39
|
+
if (!selector) {
|
|
40
|
+
incompleteNodes.push(node);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Reset focus to body
|
|
44
|
+
await cdp.send('Runtime.evaluate', {
|
|
45
|
+
expression: 'document.activeElement?.blur(); void 0;',
|
|
46
|
+
returnByValue: true,
|
|
47
|
+
});
|
|
48
|
+
// Focus the skip link via element.focus() for reliability
|
|
49
|
+
await cdp.send('Runtime.evaluate', {
|
|
50
|
+
expression: `(() => {
|
|
51
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
52
|
+
if (el) el.focus();
|
|
53
|
+
})()`,
|
|
54
|
+
returnByValue: true,
|
|
55
|
+
});
|
|
56
|
+
// Wait for CSS transitions to settle
|
|
57
|
+
await delay(settleDelay);
|
|
58
|
+
// Check visibility
|
|
59
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
60
|
+
expression: `(() => {
|
|
61
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
62
|
+
if (!el) return null;
|
|
63
|
+
const isFocused = document.activeElement === el;
|
|
64
|
+
const rect = el.getBoundingClientRect();
|
|
65
|
+
const cs = window.getComputedStyle(el);
|
|
66
|
+
return {
|
|
67
|
+
isFocused,
|
|
68
|
+
width: rect.width,
|
|
69
|
+
height: rect.height,
|
|
70
|
+
top: rect.top,
|
|
71
|
+
left: rect.left,
|
|
72
|
+
right: rect.right,
|
|
73
|
+
bottom: rect.bottom,
|
|
74
|
+
opacity: cs.opacity,
|
|
75
|
+
visibility: cs.visibility,
|
|
76
|
+
display: cs.display,
|
|
77
|
+
clip: cs.clip,
|
|
78
|
+
clipPath: cs.clipPath,
|
|
79
|
+
position: cs.position,
|
|
80
|
+
overflow: cs.overflow,
|
|
81
|
+
viewportWidth: window.innerWidth,
|
|
82
|
+
viewportHeight: window.innerHeight,
|
|
83
|
+
};
|
|
84
|
+
})()`,
|
|
85
|
+
returnByValue: true,
|
|
86
|
+
});
|
|
87
|
+
const data = result.result.value;
|
|
88
|
+
if (!data) {
|
|
89
|
+
incompleteNodes.push(node);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (isVisible(data)) {
|
|
93
|
+
passNodes.push(node);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
violationNodes.push(node);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
applyPromotions(clone, index, rule, {
|
|
100
|
+
passNodes,
|
|
101
|
+
violationNodes,
|
|
102
|
+
incompleteNodes,
|
|
103
|
+
});
|
|
104
|
+
return clone;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Determine if a skip link is visible on screen after receiving focus.
|
|
108
|
+
*/
|
|
109
|
+
function isVisible(data) {
|
|
110
|
+
// Must have non-zero dimensions
|
|
111
|
+
if (data.width <= 0 || data.height <= 0)
|
|
112
|
+
return false;
|
|
113
|
+
// Must not be display: none or visibility: hidden
|
|
114
|
+
if (data.display === 'none')
|
|
115
|
+
return false;
|
|
116
|
+
if (data.visibility === 'hidden')
|
|
117
|
+
return false;
|
|
118
|
+
// Must not be fully transparent
|
|
119
|
+
if (data.opacity === '0')
|
|
120
|
+
return false;
|
|
121
|
+
// Must not be clipped to zero size
|
|
122
|
+
const clipZero = /rect\(\s*0(px)?\s*,?\s*0(px)?\s*,?\s*0(px)?\s*,?\s*0(px)?\s*\)/;
|
|
123
|
+
if (clipZero.test(data.clip))
|
|
124
|
+
return false;
|
|
125
|
+
if (data.clipPath === 'inset(100%)')
|
|
126
|
+
return false;
|
|
127
|
+
// Must be within viewport (not positioned off-screen)
|
|
128
|
+
if (data.right <= 0)
|
|
129
|
+
return false;
|
|
130
|
+
if (data.bottom <= 0)
|
|
131
|
+
return false;
|
|
132
|
+
if (data.left >= data.viewportWidth)
|
|
133
|
+
return false;
|
|
134
|
+
if (data.top >= data.viewportHeight)
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
function delay(ms) {
|
|
139
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module target-size
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `target-size` incomplete rule
|
|
5
|
+
* (WCAG 2.5.8 Target Size Minimum). Verifies that interactive
|
|
6
|
+
* elements have at least 24×24 CSS pixels or sufficient spacing.
|
|
7
|
+
*/
|
|
8
|
+
import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
|
|
9
|
+
import type { AxeResults, TargetSizeOptions } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Resolve incomplete `target-size` results.
|
|
12
|
+
*
|
|
13
|
+
* Checks each flagged element's rendered bounding box:
|
|
14
|
+
* 1. If width ≥ 24 AND height ≥ 24 → **Pass**
|
|
15
|
+
* 2. If undersized, calculates center-to-center distance to
|
|
16
|
+
* nearest interactive neighbor. If distance ≥ 24px → **Pass**
|
|
17
|
+
* 3. Otherwise → **Violation**
|
|
18
|
+
*
|
|
19
|
+
* @param cdp - CDP session for querying bounding boxes.
|
|
20
|
+
* @param axeResults - Raw axe-core results.
|
|
21
|
+
* @param options - Optional size threshold override.
|
|
22
|
+
* @returns Modified results with resolved findings.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveTargetSize(cdp: CDPSessionLike, axeResults: AxeResults, options?: TargetSizeOptions): Promise<AxeResults>;
|
|
25
|
+
//# sourceMappingURL=target-size.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"target-size.d.ts","sourceRoot":"","sources":["../../../src/lib/resolvers/target-size.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAW,iBAAiB,EAAE,MAAM,aAAa,CAAC;AA8B1E;;;;;;;;;;;;;GAaG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,cAAc,EACnB,UAAU,EAAE,UAAU,EACtB,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,UAAU,CAAC,CAuFrB"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module target-size
|
|
3
|
+
*
|
|
4
|
+
* Resolver for axe-core's `target-size` incomplete rule
|
|
5
|
+
* (WCAG 2.5.8 Target Size Minimum). Verifies that interactive
|
|
6
|
+
* elements have at least 24×24 CSS pixels or sufficient spacing.
|
|
7
|
+
*/
|
|
8
|
+
import { getSelector, cloneResults, findIncompleteRule, applyPromotions, } from '../resolver-pipeline.js';
|
|
9
|
+
const RULE_ID = 'target-size';
|
|
10
|
+
/** Default minimum target dimension in CSS pixels. */
|
|
11
|
+
const DEFAULT_MIN_SIZE = 24;
|
|
12
|
+
/** CSS selector for interactive elements. */
|
|
13
|
+
const INTERACTIVE_SELECTOR = [
|
|
14
|
+
'a[href]',
|
|
15
|
+
'button:not([disabled])',
|
|
16
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
17
|
+
'select:not([disabled])',
|
|
18
|
+
'textarea:not([disabled])',
|
|
19
|
+
'[role="button"]',
|
|
20
|
+
'[role="link"]',
|
|
21
|
+
'[role="checkbox"]',
|
|
22
|
+
'[role="radio"]',
|
|
23
|
+
'[role="tab"]',
|
|
24
|
+
'[role="menuitem"]',
|
|
25
|
+
'[role="option"]',
|
|
26
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
27
|
+
].join(', ');
|
|
28
|
+
/**
|
|
29
|
+
* Resolve incomplete `target-size` results.
|
|
30
|
+
*
|
|
31
|
+
* Checks each flagged element's rendered bounding box:
|
|
32
|
+
* 1. If width ≥ 24 AND height ≥ 24 → **Pass**
|
|
33
|
+
* 2. If undersized, calculates center-to-center distance to
|
|
34
|
+
* nearest interactive neighbor. If distance ≥ 24px → **Pass**
|
|
35
|
+
* 3. Otherwise → **Violation**
|
|
36
|
+
*
|
|
37
|
+
* @param cdp - CDP session for querying bounding boxes.
|
|
38
|
+
* @param axeResults - Raw axe-core results.
|
|
39
|
+
* @param options - Optional size threshold override.
|
|
40
|
+
* @returns Modified results with resolved findings.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveTargetSize(cdp, axeResults, options) {
|
|
43
|
+
const clone = cloneResults(axeResults);
|
|
44
|
+
const found = findIncompleteRule(clone, RULE_ID);
|
|
45
|
+
if (!found)
|
|
46
|
+
return clone;
|
|
47
|
+
const { index, rule } = found;
|
|
48
|
+
const minSize = options?.minSize ?? DEFAULT_MIN_SIZE;
|
|
49
|
+
const passNodes = [];
|
|
50
|
+
const violationNodes = [];
|
|
51
|
+
const incompleteNodes = [];
|
|
52
|
+
for (const node of rule.nodes) {
|
|
53
|
+
const selector = getSelector(node);
|
|
54
|
+
if (!selector) {
|
|
55
|
+
incompleteNodes.push(node);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// Get bounding box of target element and all interactive neighbors
|
|
59
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
60
|
+
expression: `(() => {
|
|
61
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
62
|
+
if (!el) return null;
|
|
63
|
+
const rect = el.getBoundingClientRect();
|
|
64
|
+
const target = {
|
|
65
|
+
x: rect.x + rect.width / 2,
|
|
66
|
+
y: rect.y + rect.height / 2,
|
|
67
|
+
width: rect.width,
|
|
68
|
+
height: rect.height,
|
|
69
|
+
};
|
|
70
|
+
// If already large enough, no need to check neighbors
|
|
71
|
+
if (rect.width >= ${minSize} && rect.height >= ${minSize}) {
|
|
72
|
+
return { target, meetsMinSize: true, minDistance: null };
|
|
73
|
+
}
|
|
74
|
+
// Find nearest interactive neighbor
|
|
75
|
+
const all = document.querySelectorAll(${JSON.stringify(INTERACTIVE_SELECTOR)});
|
|
76
|
+
let minDist = Infinity;
|
|
77
|
+
for (const other of all) {
|
|
78
|
+
if (other === el) continue;
|
|
79
|
+
const oRect = other.getBoundingClientRect();
|
|
80
|
+
if (oRect.width === 0 || oRect.height === 0) continue;
|
|
81
|
+
const cx = oRect.x + oRect.width / 2;
|
|
82
|
+
const cy = oRect.y + oRect.height / 2;
|
|
83
|
+
const dist = Math.sqrt(
|
|
84
|
+
Math.pow(target.x - cx, 2) + Math.pow(target.y - cy, 2)
|
|
85
|
+
);
|
|
86
|
+
minDist = Math.min(minDist, dist);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
target,
|
|
90
|
+
meetsMinSize: false,
|
|
91
|
+
minDistance: minDist === Infinity ? null : minDist,
|
|
92
|
+
};
|
|
93
|
+
})()`,
|
|
94
|
+
returnByValue: true,
|
|
95
|
+
});
|
|
96
|
+
const data = result.result.value;
|
|
97
|
+
if (!data) {
|
|
98
|
+
incompleteNodes.push(node);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (data.meetsMinSize) {
|
|
102
|
+
passNodes.push(node);
|
|
103
|
+
}
|
|
104
|
+
else if (data.minDistance !== null && data.minDistance >= minSize) {
|
|
105
|
+
// Undersized but has sufficient spacing
|
|
106
|
+
passNodes.push(node);
|
|
107
|
+
}
|
|
108
|
+
else if (data.minDistance === null) {
|
|
109
|
+
// Undersized but no neighbors found (only interactive element)
|
|
110
|
+
// This is technically a pass for the spacing exemption,
|
|
111
|
+
// but the element itself is still undersized → Violation
|
|
112
|
+
violationNodes.push(node);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Undersized and too close to a neighbor
|
|
116
|
+
violationNodes.push(node);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
applyPromotions(clone, index, rule, {
|
|
120
|
+
passNodes,
|
|
121
|
+
violationNodes,
|
|
122
|
+
incompleteNodes,
|
|
123
|
+
});
|
|
124
|
+
return clone;
|
|
125
|
+
}
|