Skip to content

Commit 9db3fa6

Browse files
rajathmr2000claude
andcommitted
AXE-3625: Emit bulk-review visualHelperData for axe-core rules
Add reviewPayload.visualHelperData emits for aria-valid-attr-value, aria-hidden-focus, identical-links-same-purpose and duplicate-id-aria (dedicated evaluate/after), plus their unit specs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7b2a6b3 commit 9db3fa6

14 files changed

Lines changed: 356 additions & 9 deletions

lib/checks/aria/aria-valid-attr-value-evaluate.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,11 @@ export default function ariaValidAttrValueEvaluate(node, options, virtualNode) {
140140
}
141141

142142
if (needsReview) {
143-
this.data({ messageKey, needsReview });
143+
this.data({
144+
messageKey,
145+
needsReview,
146+
reviewPayload: { visualHelperData: { ariaAttribute: needsReview } }
147+
});
144148
return undefined;
145149
}
146150

lib/checks/keyboard/focusable-disabled-evaluate.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function focusableDisabledEvaluate(node, options, virtualNode) {
2525
return true;
2626
}
2727

28-
return relatedNodes.every(vNode => {
28+
const verdict = relatedNodes.every(vNode => {
2929
const pointerEvents = vNode.getComputedStylePropertyValue('pointer-events');
3030
const width = parseInt(vNode.getComputedStylePropertyValue('width'));
3131
const height = parseInt(vNode.getComputedStylePropertyValue('height'));
@@ -37,6 +37,28 @@ function focusableDisabledEvaluate(node, options, virtualNode) {
3737
})
3838
? undefined
3939
: false;
40+
41+
// a11y-rule-aria-hidden-focus: surface the focusable descendants for the
42+
// bulk-review "Review Hidden Elements" visual helper. Reuse DqElement.selector
43+
// (the same format-aware path relatedNodes uses) so the list and the overlay
44+
// share identical selectors; no new computation. Wrapped so any failure to
45+
// build the payload (e.g. an unexpected node) can never fail the check/scan.
46+
try {
47+
this.data({
48+
reviewPayload: {
49+
visualHelperData: {
50+
focusableChildren: relatedNodes
51+
.map(vNode => vNode.actualNode)
52+
.filter(Boolean)
53+
.map(actualNode => new axe.utils.DqElement(actualNode).selector)
54+
}
55+
}
56+
});
57+
} catch {
58+
// best-effort emit — preserve the scan result
59+
}
60+
61+
return verdict;
4062
}
4163

4264
export default focusableDisabledEvaluate;

lib/checks/keyboard/focusable-not-tabbable-evaluate.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function focusableNotTabbableEvaluate(node, options, virtualNode) {
2525
return true;
2626
}
2727

28-
return relatedNodes.every(vNode => {
28+
const verdict = relatedNodes.every(vNode => {
2929
const pointerEvents = vNode.getComputedStylePropertyValue('pointer-events');
3030
const width = parseInt(vNode.getComputedStylePropertyValue('width'));
3131
const height = parseInt(vNode.getComputedStylePropertyValue('height'));
@@ -37,6 +37,28 @@ function focusableNotTabbableEvaluate(node, options, virtualNode) {
3737
})
3838
? undefined
3939
: false;
40+
41+
// a11y-rule-aria-hidden-focus: surface the focusable descendants for the
42+
// bulk-review "Review Hidden Elements" visual helper. Reuse DqElement.selector
43+
// (the same format-aware path relatedNodes uses) so the list and the overlay
44+
// share identical selectors; no new computation. Wrapped so any failure to
45+
// build the payload (e.g. an unexpected node) can never fail the check/scan.
46+
try {
47+
this.data({
48+
reviewPayload: {
49+
visualHelperData: {
50+
focusableChildren: relatedNodes
51+
.map(vNode => vNode.actualNode)
52+
.filter(Boolean)
53+
.map(actualNode => new axe.utils.DqElement(actualNode).selector)
54+
}
55+
}
56+
});
57+
} catch {
58+
// best-effort emit — preserve the scan result
59+
}
60+
61+
return verdict;
4062
}
4163

4264
export default focusableNotTabbableEvaluate;

lib/checks/navigation/identical-links-same-purpose-after.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ function identicalLinksSamePurposeAfter(results) {
8484
currentResult.result = undefined;
8585
}
8686

87+
/**
88+
* The node's own DqElement is stored at `relatedNodes[0]` by the evaluate
89+
* fn; capture it before `relatedNodes` is rebuilt so the link group can
90+
* include the primary link, not just its same-name siblings.
91+
*/
92+
const currentNode = currentResult.relatedNodes[0];
93+
8794
/**
8895
* -> deduplicate results (for both `pass` or `incomplete`) and add `relatedNodes` if any
8996
*/
@@ -92,6 +99,24 @@ function identicalLinksSamePurposeAfter(results) {
9299
...sameNameResults.map(node => node.relatedNodes[0])
93100
);
94101

102+
if (sameNameResults.length) {
103+
try {
104+
const identicalLinks = [currentNode, ...currentResult.relatedNodes]
105+
.filter(Boolean)
106+
.map(dqElm => (dqElm ? dqElm.selector : undefined))
107+
.filter(Boolean);
108+
const linkText =
109+
(currentResult.data && currentResult.data.accessibleText) || name;
110+
if (currentResult.data) {
111+
currentResult.data.reviewPayload = {
112+
visualHelperData: { linkText, identicalLinks }
113+
};
114+
}
115+
} catch {
116+
// best-effort emit — preserve the reconciled results
117+
}
118+
}
119+
95120
/**
96121
* Update `nodeMap` with `sameNameResults`
97122
*/

lib/checks/navigation/identical-links-same-purpose-evaluate.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function identicalLinksSamePurposeEvaluate(node, options, virtualNode) {
2424
*/
2525
const afterData = {
2626
name,
27+
accessibleText: text.sanitize(accText),
2728
urlProps: dom.urlPropsFromAttribute(node, 'href')
2829
};
2930
this.data(afterData);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Mirrors `duplicate-id-after`, but dedupes by `r.data.id` since the aria
3+
* evaluate stores its data as an object (`{ id, reviewPayload }`) rather than
4+
* a bare id string.
5+
*/
6+
function duplicateIdAriaAfter(results) {
7+
const uniqueIds = [];
8+
return results.filter(r => {
9+
const id = r.data && r.data.id;
10+
if (uniqueIds.indexOf(id) === -1) {
11+
uniqueIds.push(id);
12+
return true;
13+
}
14+
return false;
15+
});
16+
}
17+
18+
export default duplicateIdAriaAfter;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { getRootNode } from '../../commons/dom';
2+
import { escapeSelector } from '../../core/utils';
3+
4+
/**
5+
* Mirrors `duplicate-id-evaluate`, but additionally emits a `reviewPayload`
6+
* for the bulk-review "Review Duplicate IDs" visual helper. Kept separate so
7+
* the non-bulk-review `duplicate-id` / `duplicate-id-active` checks (which
8+
* share `duplicate-id-evaluate`) are untouched.
9+
*/
10+
function duplicateIdAriaEvaluate(node) {
11+
const id = node.getAttribute('id').trim();
12+
// Since empty ID's are not meaningful and are ignored by Edge, we'll
13+
// let those pass.
14+
if (!id) {
15+
return true;
16+
}
17+
const root = getRootNode(node);
18+
const matchingNodes = Array.from(
19+
root.querySelectorAll(`[id="${escapeSelector(id)}"]`)
20+
).filter(foundNode => foundNode !== node);
21+
22+
if (matchingNodes.length) {
23+
this.relatedNodes(matchingNodes);
24+
}
25+
26+
// Verdict is computed independently of the emit below.
27+
const verdict = matchingNodes.length === 0;
28+
29+
// a11y-rule-duplicate-id-aria: surface the duplicate id and the other
30+
// elements sharing it for the visual helper. Reuse `DqElement.selector`
31+
// (the same path `relatedNodes` serializes with) so the list matches the
32+
// reported nodes; no new lookup. Emit only when a duplicate exists, and
33+
// wrapped so any failure to build the payload can never fail the check/scan
34+
// (the `id` is always carried for the fail message).
35+
const data = { id };
36+
try {
37+
if (matchingNodes.length) {
38+
data.reviewPayload = {
39+
visualHelperData: {
40+
duplicateId: id,
41+
elements: matchingNodes
42+
.filter(Boolean)
43+
.map(foundNode => new axe.utils.DqElement(foundNode).selector)
44+
}
45+
};
46+
}
47+
} catch {
48+
// best-effort emit — preserve the scan result
49+
}
50+
this.data(data);
51+
52+
return verdict;
53+
}
54+
55+
export default duplicateIdAriaEvaluate;
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
22
"id": "duplicate-id-aria",
3-
"evaluate": "duplicate-id-evaluate",
4-
"after": "duplicate-id-after",
3+
"evaluate": "duplicate-id-aria-evaluate",
4+
"after": "duplicate-id-aria-after",
55
"metadata": {
66
"impact": "critical",
77
"messages": {
88
"pass": "Document has no elements referenced with ARIA or labels that share the same id attribute",
9-
"fail": "Document has multiple elements referenced with ARIA with the same id attribute: ${data}"
9+
"fail": "Document has multiple elements referenced with ARIA with the same id attribute: ${data.id}"
1010
}
1111
}
1212
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
describe('aria-valid-attr-value', () => {
2+
'use strict';
3+
4+
const checkContext = axe.testUtils.MockCheckContext();
5+
const checkSetup = axe.testUtils.checkSetup;
6+
const checkEvaluate = axe.testUtils.getCheckEvaluate('aria-valid-attr-value');
7+
8+
afterEach(() => {
9+
checkContext.reset();
10+
});
11+
12+
it('emits the flagged aria-current attribute in reviewPayload.visualHelperData', () => {
13+
const params = checkSetup(
14+
'<div id="target" aria-current="active">Contents</div>'
15+
);
16+
assert.isUndefined(checkEvaluate.apply(checkContext, params));
17+
assert.equal(checkContext._data.messageKey, 'ariaCurrent');
18+
assert.equal(checkContext._data.needsReview, 'aria-current="active"');
19+
assert.deepEqual(checkContext._data.reviewPayload, {
20+
visualHelperData: { ariaAttribute: 'aria-current="active"' }
21+
});
22+
});
23+
24+
it('emits the flagged aria-labelledby attribute in reviewPayload.visualHelperData', () => {
25+
const params = checkSetup(
26+
'<div id="target" aria-labelledby="missing-id">Contents</div>'
27+
);
28+
assert.isUndefined(checkEvaluate.apply(checkContext, params));
29+
assert.equal(
30+
checkContext._data.needsReview,
31+
'aria-labelledby="missing-id"'
32+
);
33+
assert.deepEqual(checkContext._data.reviewPayload, {
34+
visualHelperData: { ariaAttribute: 'aria-labelledby="missing-id"' }
35+
});
36+
});
37+
38+
it('does not add a reviewPayload on the invalid-value (violation) path', () => {
39+
const params = checkSetup(
40+
'<div id="target" role="checkbox" aria-checked="foo">Contents</div>'
41+
);
42+
assert.isFalse(checkEvaluate.apply(checkContext, params));
43+
assert.isArray(checkContext._data);
44+
assert.notProperty(checkContext._data, 'reviewPayload');
45+
});
46+
});

test/checks/keyboard/focusable-disabled.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,19 @@ describe('focusable-disabled', function () {
218218
);
219219
assert.isUndefined(check.evaluate.apply(checkContext, params));
220220
});
221+
222+
it('emits the focusable children selectors in reviewPayload.visualHelperData', () => {
223+
var params = checkSetup(
224+
'<fieldset id="target" aria-hidden="true"><input /></fieldset>'
225+
);
226+
assert.isFalse(check.evaluate.apply(checkContext, params));
227+
var focusableChildren =
228+
checkContext._data.reviewPayload.visualHelperData.focusableChildren;
229+
assert.isArray(focusableChildren);
230+
assert.lengthOf(focusableChildren, 1);
231+
// each entry is a DqElement.selector array, identical to relatedNodes[].selector
232+
assert.isArray(focusableChildren[0]);
233+
assert.isString(focusableChildren[0][0]);
234+
assert.match(focusableChildren[0][0], /input/);
235+
});
221236
});

0 commit comments

Comments
 (0)