@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,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
+ }