Skip to content

Commit 34cb30b

Browse files
committed
fix: treat mustache empty strings as no accessible name; add aria-hidden string-literal tests
1 parent 0587201 commit 34cb30b

2 files changed

Lines changed: 76 additions & 0 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,22 @@ function hasAccessibleNameAttribute(node) {
3737
if (!attr) {
3838
continue;
3939
}
40+
if (attr.value?.type === 'GlimmerMustacheStatement') {
41+
const resolved = getStaticAttrValue(attr.value);
42+
if (resolved === undefined) {
43+
// Truly dynamic (e.g. `aria-label={{@label}}`) — can't know at lint
44+
// time; give the author the benefit of the doubt.
45+
return true;
46+
}
47+
// Static string literal in mustache, e.g. `aria-label={{""}}`.
48+
// Treat exactly like a plain text value: non-empty means a name exists.
49+
if (resolved.trim().length > 0) {
50+
return true;
51+
}
52+
continue;
53+
}
4054
if (isDynamicValue(attr.value)) {
55+
// GlimmerConcatStatement — treat as dynamic.
4156
return true;
4257
}
4358
if (attr.value?.type === 'GlimmerTextNode') {
@@ -105,7 +120,18 @@ function evaluateChild(child, sourceCode) {
105120
// Missing alt is a separate a11y concern; treat as no contribution.
106121
return { dynamic: false, accessible: false };
107122
}
123+
if (altAttr.value?.type === 'GlimmerMustacheStatement') {
124+
const resolved = getStaticAttrValue(altAttr.value);
125+
if (resolved === undefined) {
126+
// Truly dynamic (e.g. `alt={{@alt}}`) — trust the author.
127+
return { dynamic: true, accessible: false };
128+
}
129+
// Static string literal in mustache, e.g. `alt={{""}}` or
130+
// `alt={{"Search"}}` — treat exactly like a plain text value.
131+
return { dynamic: false, accessible: resolved.trim().length > 0 };
132+
}
108133
if (isDynamicValue(altAttr.value)) {
134+
// GlimmerConcatStatement — treat as dynamic.
109135
return { dynamic: true, accessible: false };
110136
}
111137
if (altAttr.value?.type === 'GlimmerTextNode') {

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ ruleTester.run('template-anchor-has-content', rule, {
4646
code: '<template><a href="/x"><img alt={{@alt}} /></a></template>',
4747
},
4848

49+
// <img alt={{"…"}}> — static string in mustache; non-empty alt counts as
50+
// accessible content.
51+
{
52+
filename: 'test.gjs',
53+
code: '<template><a href="/x"><img alt={{"Search"}} /></a></template>',
54+
},
55+
56+
// aria-label with a non-empty mustache string literal is a valid name.
57+
{
58+
filename: 'test.gjs',
59+
code: '<template><a href="/x" aria-label={{"Close"}} /></template>',
60+
},
61+
4962
// Component invocation (PascalCase) — not a plain HTML anchor, out of scope.
5063
{ filename: 'test.gjs', code: '<template><Link href="/x" /></template>' },
5164

@@ -101,6 +114,12 @@ ruleTester.run('template-anchor-has-content', rule, {
101114
filename: 'test.gjs',
102115
code: '<template><a href="/x" aria-hidden={{true}}></a></template>',
103116
},
117+
// aria-hidden={{"true"}} — string-literal mustache, resolved statically to
118+
// "true"; anchor is hidden from the a11y tree, check does not apply.
119+
{
120+
filename: 'test.gjs',
121+
code: '<template><a href="/x" aria-hidden={{"true"}} /></template>',
122+
},
104123

105124
// Scope-shadowed lowercase `a` (local binding in GJS) — not the native
106125
// HTML anchor, so the rule does not validate it. `isNativeElement`
@@ -185,6 +204,21 @@ ruleTester.run('template-anchor-has-content', rule, {
185204
output: null,
186205
errors: [{ messageId: 'anchorHasContent' }],
187206
},
207+
// aria-label={{""}} — static empty string in mustache is NOT a name.
208+
{
209+
filename: 'test.gjs',
210+
code: '<template><a href="/x" aria-label={{""}} /></template>',
211+
output: null,
212+
errors: [{ messageId: 'anchorHasContent' }],
213+
},
214+
// <img alt={{""}}> — static empty string in mustache is decorative;
215+
// no accessible name contributed.
216+
{
217+
filename: 'test.gjs',
218+
code: '<template><a href="/x"><img alt={{""}} /></a></template>',
219+
output: null,
220+
errors: [{ messageId: 'anchorHasContent' }],
221+
},
188222
// `&nbsp;` is normalized to space — `aria-label="&nbsp;"` is functionally
189223
// empty for assistive tech and should not count as an accessible name.
190224
{
@@ -222,6 +256,22 @@ ruleTester.run('template-anchor-has-content', rule, {
222256
output: null,
223257
errors: [{ messageId: 'anchorHasContent' }],
224258
},
259+
// aria-hidden={{"false"}} — string-literal mustache resolves to "false";
260+
// anchor is NOT hidden, so the content check applies and should flag.
261+
{
262+
filename: 'test.gjs',
263+
code: '<template><a href="/x" aria-hidden={{"false"}} /></template>',
264+
output: null,
265+
errors: [{ messageId: 'anchorHasContent' }],
266+
},
267+
// aria-hidden={{"true"}} on a child — child is hidden, contributes nothing;
268+
// the anchor itself is still in scope and has no accessible content.
269+
{
270+
filename: 'test.gjs',
271+
code: '<template><a href="/x"><span aria-hidden={{"true"}}>X</span></a></template>',
272+
output: null,
273+
errors: [{ messageId: 'anchorHasContent' }],
274+
},
225275
],
226276
});
227277

0 commit comments

Comments
 (0)