Skip to content

Commit 0d8a99e

Browse files
authored
feat(new-rule): summary elements must have an accessible name (#4511)
This rule checks that summary elements have an accessible name, through text content, aria-label(ledby) or title. It skips summary elements that are not used as controls for `details`, or if its `details` element has no content. Closes: #4510
1 parent 0577a74 commit 0d8a99e

File tree

12 files changed

+383
-2
lines changed

12 files changed

+383
-2
lines changed

doc/rule-descriptions.md

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
| [scrollable-region-focusable](https://dequeuniversity.com/rules/axe/4.9/scrollable-region-focusable?application=RuleDescription) | Ensure elements that have scrollable content are accessible by keyboard | Serious | cat.keyboard, wcag2a, wcag211, wcag213, TTv5, TT4.a, EN-301-549, EN-9.2.1.1, EN-9.2.1.3 | failure | [0ssw9k](https://act-rules.github.io/rules/0ssw9k) |
7070
| [select-name](https://dequeuniversity.com/rules/axe/4.9/select-name?application=RuleDescription) | Ensures select element has an accessible name | Critical | cat.forms, wcag2a, wcag412, section508, section508.22.n, TTv5, TT5.c, EN-301-549, EN-9.4.1.2, ACT | failure, needs review | [e086e5](https://act-rules.github.io/rules/e086e5) |
7171
| [server-side-image-map](https://dequeuniversity.com/rules/axe/4.9/server-side-image-map?application=RuleDescription) | Ensures that server-side image maps are not used | Minor | cat.text-alternatives, wcag2a, wcag211, section508, section508.22.f, TTv5, TT4.a, EN-301-549, EN-9.2.1.1 | needs review | |
72+
| [summary-name](https://dequeuniversity.com/rules/axe/4.9/summary-name?application=RuleDescription) | Ensures summary elements have discernible text | Serious | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a, TTv5, TT6.a, EN-301-549, EN-9.4.1.2 | failure, needs review | |
7273
| [svg-img-alt](https://dequeuniversity.com/rules/axe/4.9/svg-img-alt?application=RuleDescription) | Ensures <svg> elements with an img, graphics-document or graphics-symbol role have an accessible text | Serious | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a, TTv5, TT7.a, EN-301-549, EN-9.1.1.1, ACT | failure, needs review | [7d6734](https://act-rules.github.io/rules/7d6734) |
7374
| [td-headers-attr](https://dequeuniversity.com/rules/axe/4.9/td-headers-attr?application=RuleDescription) | Ensure that each cell in a table that uses the headers attribute refers only to other cells in that table | Serious | cat.tables, wcag2a, wcag131, section508, section508.22.g, TTv5, TT14.b, EN-301-549, EN-9.1.3.1 | failure, needs review | [a25f45](https://act-rules.github.io/rules/a25f45) |
7475
| [th-has-data-cells](https://dequeuniversity.com/rules/axe/4.9/th-has-data-cells?application=RuleDescription) | Ensure that <th> elements and elements with role=columnheader/rowheader have data cells they describe | Serious | cat.tables, wcag2a, wcag131, section508, section508.22.g, TTv5, TT14.b, EN-301-549, EN-9.1.3.1 | failure, needs review | [d0f69e](https://act-rules.github.io/rules/d0f69e) |
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default function summaryIsInteractiveMatches(_, virtualNode) {
2+
// Summary only interactive if its real DOM parent is a details element
3+
const parent = virtualNode.parent;
4+
if (parent.props.nodeName !== 'details' || isSlottedElm(virtualNode)) {
5+
return false;
6+
}
7+
// Only the first summary element is interactive
8+
const firstSummary = parent.children.find(
9+
child => child.props.nodeName === 'summary'
10+
);
11+
if (firstSummary !== virtualNode) {
12+
return false;
13+
}
14+
return true;
15+
}
16+
17+
function isSlottedElm(vNode) {
18+
// Normally this wouldn't be enough, but since we know parent is a details
19+
// element, we can ignore edge cases like slot being the real parent
20+
const domParent = vNode.actualNode?.parentElement;
21+
return domParent && domParent !== vNode.parent.actualNode;
22+
}

lib/rules/summary-name.json

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"id": "summary-name",
3+
"impact": "serious",
4+
"selector": "summary",
5+
"matches": "summary-interactive-matches",
6+
"tags": [
7+
"cat.name-role-value",
8+
"wcag2a",
9+
"wcag412",
10+
"section508",
11+
"section508.22.a",
12+
"TTv5",
13+
"TT6.a",
14+
"EN-301-549",
15+
"EN-9.4.1.2"
16+
],
17+
"metadata": {
18+
"description": "Ensures summary elements have discernible text",
19+
"help": "Summary elements must have discernible text"
20+
},
21+
"all": [],
22+
"any": [
23+
"has-visible-text",
24+
"aria-label",
25+
"aria-labelledby",
26+
"non-empty-title"
27+
],
28+
"none": []
29+
}

locales/_template.json

+4
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@
373373
"description": "Ensure all skip links have a focusable target",
374374
"help": "The skip-link target should exist and be focusable"
375375
},
376+
"summary-name": {
377+
"description": "Ensures summary elements have discernible text",
378+
"help": "Summary elements must have discernible text"
379+
},
376380
"svg-img-alt": {
377381
"description": "Ensures <svg> elements with an img, graphics-document or graphics-symbol role have an accessible text",
378382
"help": "<svg> elements with an img role must have an alternative text"

test/integration/full/all-rules/all-rules.html

+4
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ <h2>Ok</h2>
129129
<li>Hello</li>
130130
<li>World</li>
131131
</ul>
132+
<details>
133+
<summary>pass</summary>
134+
<p>Hello world</p>
135+
</details>
132136

133137
<div style="height: 100vh">Large scroll area</div>
134138
<button id="end-of-page">End of page</button>

test/integration/full/isolated-env/isolated-env.html

+4
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ <h2>Ok</h2>
103103
<button id="fail1"></button>
104104
<span id="pass1"></span>
105105
<button id="pass2"></button>
106+
<details>
107+
<summary>Hello world</summary>
108+
<p>Some text</p>
109+
</details>
106110
<div aria-labelledby="fail1 pass1 pass2"></div>
107111
<audio
108112
id="incomplete1"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<details>
2+
<summary id="empty-fail"></summary>
3+
Hello world
4+
</details>
5+
6+
<details>
7+
<summary id="text-pass">name</summary>
8+
Hello world
9+
</details>
10+
11+
<details>
12+
<summary id="aria-label-pass" aria-label="Name"></summary>
13+
Hello world
14+
</details>
15+
16+
<details>
17+
<summary id="aria-label-fail" aria-label=""></summary>
18+
Hello world
19+
</details>
20+
21+
<details>
22+
<summary id="aria-labelledby-pass" aria-labelledby="labeldiv"></summary>
23+
Hello world
24+
</details>
25+
26+
<details>
27+
<summary id="aria-labelledby-fail" aria-labelledby="nonexistent"></summary>
28+
Hello world
29+
</details>
30+
31+
<details>
32+
<summary id="aria-labelledby-empty-fail" aria-labelledby="emptydiv"></summary>
33+
Hello world
34+
</details>
35+
<div id="labeldiv">summary label</div>
36+
<div id="emptydiv"></div>
37+
38+
<details>
39+
<summary id="combo-pass" aria-label="Aria Name">Name</summary>
40+
Hello world
41+
</details>
42+
43+
<details>
44+
<summary id="title-pass" title="Title"></summary>
45+
Hello world
46+
</details>
47+
48+
<details>
49+
<summary id="presentation-role-fail" role="presentation"></summary>
50+
Conflict resolution gets this to be ignored
51+
</details>
52+
53+
<details>
54+
<summary id="none-role-fail" role="none"></summary>
55+
Conflict resolution gets this to be ignored
56+
</details>
57+
58+
<details>
59+
<summary id="heading-role-fail" role="heading"></summary>
60+
Conflict resolution gets this to be ignored
61+
</details>
62+
63+
<!-- Invalid naming methods -->
64+
65+
<details>
66+
<summary id="value-attr-fail" value="Button Name"></summary>
67+
Not a valid method for giving a name
68+
</details>
69+
70+
<details>
71+
<summary id="alt-attr-fail" alt="Button Name"></summary>
72+
Not a valid method for giving a name
73+
</details>
74+
75+
<label>
76+
<details>
77+
<summary id="label-elm-fail"></summary>
78+
Text here
79+
</details>
80+
Not a valid method for giving a name
81+
</label>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"description": "summary-name test",
3+
"rule": "summary-name",
4+
"violations": [
5+
["#empty-fail"],
6+
["#aria-label-fail"],
7+
["#aria-labelledby-fail"],
8+
["#aria-labelledby-empty-fail"],
9+
["#presentation-role-fail"],
10+
["#none-role-fail"],
11+
["#heading-role-fail"],
12+
["#value-attr-fail"],
13+
["#alt-attr-fail"],
14+
["#label-elm-fail"]
15+
],
16+
"passes": [
17+
["#text-pass"],
18+
["#aria-label-pass"],
19+
["#aria-labelledby-pass"],
20+
["#combo-pass"],
21+
["#title-pass"]
22+
]
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
function appendSerialChild(parent, child) {
2+
if (child instanceof axe.SerialVirtualNode === false) {
3+
child = new axe.SerialVirtualNode(child);
4+
}
5+
child.parent = parent;
6+
parent.children ??= [];
7+
parent.children.push(child);
8+
return child;
9+
}
10+
11+
describe('summary-name virtual-rule', () => {
12+
let vDetails;
13+
beforeEach(() => {
14+
vDetails = new axe.SerialVirtualNode({
15+
nodeName: 'details',
16+
attributes: {}
17+
});
18+
appendSerialChild(vDetails, { nodeName: '#text', nodeValue: 'text' });
19+
});
20+
21+
it('fails without children', () => {
22+
const vSummary = new axe.SerialVirtualNode({
23+
nodeName: 'summary',
24+
attributes: {}
25+
});
26+
vSummary.children = [];
27+
appendSerialChild(vDetails, vSummary);
28+
const results = axe.runVirtualRule('summary-name', vSummary);
29+
console.log(results);
30+
assert.lengthOf(results.passes, 0);
31+
assert.lengthOf(results.violations, 1);
32+
assert.lengthOf(results.incomplete, 0);
33+
});
34+
35+
it('passes with text content', () => {
36+
const vSummary = new axe.SerialVirtualNode({
37+
nodeName: 'summary',
38+
attributes: {}
39+
});
40+
appendSerialChild(vSummary, { nodeName: '#text', nodeValue: 'text' });
41+
appendSerialChild(vDetails, vSummary);
42+
43+
const results = axe.runVirtualRule('summary-name', vSummary);
44+
assert.lengthOf(results.passes, 1);
45+
assert.lengthOf(results.violations, 0);
46+
assert.lengthOf(results.incomplete, 0);
47+
});
48+
49+
it('passes with aria-label', () => {
50+
const vSummary = new axe.SerialVirtualNode({
51+
nodeName: 'summary',
52+
attributes: { 'aria-label': 'foobar' }
53+
});
54+
appendSerialChild(vDetails, vSummary);
55+
const results = axe.runVirtualRule('summary-name', vSummary);
56+
assert.lengthOf(results.passes, 1);
57+
assert.lengthOf(results.violations, 0);
58+
assert.lengthOf(results.incomplete, 0);
59+
});
60+
61+
it('passes with title', () => {
62+
const vSummary = new axe.SerialVirtualNode({
63+
nodeName: 'summary',
64+
attributes: { title: 'foobar' }
65+
});
66+
appendSerialChild(vDetails, vSummary);
67+
const results = axe.runVirtualRule('summary-name', vSummary);
68+
assert.lengthOf(results.passes, 1);
69+
assert.lengthOf(results.violations, 0);
70+
assert.lengthOf(results.incomplete, 0);
71+
});
72+
73+
it('incompletes with aria-labelledby', () => {
74+
const vSummary = new axe.SerialVirtualNode({
75+
nodeName: 'summary',
76+
attributes: { 'aria-labelledby': 'foobar' }
77+
});
78+
appendSerialChild(vDetails, vSummary);
79+
const results = axe.runVirtualRule('summary-name', vSummary);
80+
assert.lengthOf(results.passes, 0);
81+
assert.lengthOf(results.violations, 0);
82+
assert.lengthOf(results.incomplete, 1);
83+
});
84+
85+
it('throws without a parent', () => {
86+
const vSummary = new axe.SerialVirtualNode({
87+
nodeName: 'summary',
88+
attributes: { 'aria-labelledby': 'foobar' }
89+
});
90+
vSummary.children = [];
91+
assert.throws(() => {
92+
axe.runVirtualRule('summary-name', vSummary);
93+
});
94+
});
95+
});

test/rule-matches/no-naming-method-matches.js

+6
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ describe('no-naming-method-matches', function () {
5151
assert.isFalse(actual);
5252
});
5353

54+
it('returns false when node is SUMMARY', function () {
55+
const vNode = queryFixture('<summary id="target"></summary>');
56+
const actual = rule.matches(null, vNode);
57+
assert.isFalse(actual);
58+
});
59+
5460
it('returns false for INPUT of type `BUTTON`, `SUBMIT` or `RESET`', function () {
5561
['button', 'submit', 'reset'].forEach(function (type) {
5662
const vNode = queryFixture(

0 commit comments

Comments
 (0)