Skip to content

Commit b0c0af7

Browse files
committed
fix(template-anchor-has-content): align aria-hidden detection with WAI-ARIA spec
Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string aria-hidden resolves to the default `undefined` — NOT `true`. So <span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the span; its content still contributes to the anchor's accessible name. The prior behavior inherited jsx-a11y's JSX-coercion convention and vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin conventions that diverge from normative ARIA. Matches the spec-first resolution of ember-cli#2717, #19, and #33. Moved valueless / empty aria-hidden cases from invalid → valid. Kept the explicit aria-hidden="true" and {{true}} cases as invalid.
1 parent b5cb4fd commit b0c0af7

2 files changed

Lines changed: 32 additions & 26 deletions

File tree

lib/rules/template-anchor-has-content.js

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,18 @@ function isDynamicValue(value) {
2020
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
2121
}
2222

23-
// Returns true if the `aria-hidden` attribute is effectively truthy. Mirrors
24-
// the jsx-a11y/vue-a11y convention: valueless (`aria-hidden`), string `"true"`,
25-
// or `{{true}}` all hide the element from the a11y tree. Dynamic values are
26-
// treated as "hidden" only when the developer explicitly passes boolean true;
27-
// anything we cannot statically resolve falls through as not-hidden so we
28-
// don't silently swallow meaningful content.
23+
// Returns true if the `aria-hidden` attribute is explicitly set to "true"
24+
// (case-insensitive) or mustache-literal `{{true}}`. Per WAI-ARIA 1.2 §6.6
25+
// + aria-hidden value table, valueless / empty-string `aria-hidden` resolves
26+
// to the default `undefined` — NOT `true` — so those forms do NOT hide the
27+
// element per spec. This aligns with the spec-first decisions in #2717 /
28+
// #19 / #33, and diverges from jsx-a11y's JSX-coercion convention.
2929
function isAriaHiddenTrue(attr) {
30-
if (!attr) {
30+
if (!attr?.value) {
3131
return false;
3232
}
33-
// Valueless attribute (e.g. `<span aria-hidden />`) parses with no value.
34-
if (attr.value === undefined || attr.value === null) {
35-
return true;
36-
}
3733
if (attr.value.type === 'GlimmerTextNode') {
38-
const chars = attr.value.chars.trim().toLowerCase();
39-
// HTML parses bare `aria-hidden` as `aria-hidden=""`; treat empty as true
40-
// to mirror the valueless shape above.
41-
return chars === '' || chars === 'true';
34+
return attr.value.chars.trim().toLowerCase() === 'true';
4235
}
4336
if (attr.value.type === 'GlimmerMustacheStatement') {
4437
const path = attr.value.path;

tests/lib/rules/template-anchor-has-content.js

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ ruleTester.run('template-anchor-has-content', rule, {
6464
filename: 'test.gjs',
6565
code: '<template><a href="/x"><span aria-label="close icon" /></a></template>',
6666
},
67+
68+
// Valueless / empty aria-hidden resolves to default `undefined` per
69+
// WAI-ARIA 1.2 §6.6 — the child is NOT hidden, its content counts.
70+
{
71+
filename: 'test.gjs',
72+
code: '<template><a href="/x"><span aria-hidden>X</span></a></template>',
73+
},
74+
{
75+
filename: 'test.gjs',
76+
code: '<template><a href="/x"><span aria-hidden="">X</span></a></template>',
77+
},
78+
{
79+
filename: 'test.gjs',
80+
code: '<template><a href="/x"><img aria-hidden alt="Nope" /></a></template>',
81+
},
6782
],
6883

6984
invalid: [
@@ -88,23 +103,17 @@ ruleTester.run('template-anchor-has-content', rule, {
88103
output: null,
89104
errors: [{ messageId: 'anchorHasContent' }],
90105
},
91-
// aria-hidden subtree contributes nothing to the accessible name.
106+
// aria-hidden="true" subtree contributes nothing to the accessible name.
92107
{
93108
filename: 'test.gjs',
94109
code: '<template><a href="/x"><span aria-hidden="true">X</span></a></template>',
95110
output: null,
96111
errors: [{ messageId: 'anchorHasContent' }],
97112
},
113+
// <img aria-hidden="true" alt="Nope" /> — alt not exposed when hidden.
98114
{
99115
filename: 'test.gjs',
100-
code: '<template><a href="/x"><span aria-hidden>X</span></a></template>',
101-
output: null,
102-
errors: [{ messageId: 'anchorHasContent' }],
103-
},
104-
// <img aria-hidden /> — alt is not exposed when the image is hidden.
105-
{
106-
filename: 'test.gjs',
107-
code: '<template><a href="/x"><img aria-hidden alt="Nope" /></a></template>',
116+
code: '<template><a href="/x"><img aria-hidden="true" alt="Nope" /></a></template>',
108117
output: null,
109118
errors: [{ messageId: 'anchorHasContent' }],
110119
},
@@ -170,6 +179,10 @@ hbsRuleTester.run('template-anchor-has-content (hbs)', rule, {
170179
// Anchors without href are out of scope.
171180
'<a />',
172181
'<a>Foo</a>',
182+
// Valueless aria-hidden resolves to default `undefined` per ARIA §6.6 —
183+
// child is not hidden, its content counts.
184+
'<a href="/x"><span aria-hidden>X</span></a>',
185+
'<a href="/x"><img aria-hidden alt="Nope" /></a>',
173186
],
174187
invalid: [
175188
{
@@ -183,12 +196,12 @@ hbsRuleTester.run('template-anchor-has-content (hbs)', rule, {
183196
errors: [{ messageId: 'anchorHasContent' }],
184197
},
185198
{
186-
code: '<a href="/x"><span aria-hidden>X</span></a>',
199+
code: '<a href="/x"><span aria-hidden="true">X</span></a>',
187200
output: null,
188201
errors: [{ messageId: 'anchorHasContent' }],
189202
},
190203
{
191-
code: '<a href="/x"><img aria-hidden alt="Nope" /></a>',
204+
code: '<a href="/x"><img aria-hidden="true" alt="Nope" /></a>',
192205
output: null,
193206
errors: [{ messageId: 'anchorHasContent' }],
194207
},

0 commit comments

Comments
 (0)