Skip to content

Commit 2337221

Browse files
committed
fix(template-require-valid-alt-text): reject empty-string aria-label/aria-labelledby/alt
Before: for <input type="image">, <object>, and <area>, the rule checked only for the PRESENCE of an accessible-name fallback attribute (aria-label / aria-labelledby / alt / title). An empty-string value provides no accessible name but slipped past. Fix: add hasNonEmptyTextAttr() that requires the attribute's static value to be non-whitespace. Dynamic values (mustache, concat) remain accepted — we can't tell at lint time whether they resolve to empty. <img>'s alt handling is unchanged — alt="" is still valid there (spec-defined marker for decorative images). Nine new invalid tests cover the three elements × three fallback attrs.
1 parent 24882a3 commit 2337221

2 files changed

Lines changed: 72 additions & 3 deletions

File tree

lib/rules/template-require-valid-alt-text.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,26 @@ function hasAnyAttr(node, names) {
1212
return names.some((name) => hasAttr(node, name));
1313
}
1414

15+
// For accessible-name fallback attributes (aria-label, aria-labelledby, title),
16+
// an empty string provides no accessible name — it must be checked as "any truthy
17+
// static value" or "any dynamic value". Returns true iff the attribute is
18+
// present AND will meaningfully contribute an accessible name.
19+
function hasNonEmptyTextAttr(node, name) {
20+
const attr = findAttr(node, name);
21+
if (!attr?.value) {
22+
return false;
23+
}
24+
if (attr.value.type === 'GlimmerTextNode') {
25+
return attr.value.chars.trim() !== '';
26+
}
27+
// Mustache / concat — dynamic; assume truthy.
28+
return true;
29+
}
30+
31+
function hasAnyNonEmptyTextAttr(node, names) {
32+
return names.some((name) => hasNonEmptyTextAttr(node, name));
33+
}
34+
1535
function getTextValue(attr) {
1636
if (!attr?.value) {
1737
return undefined;
@@ -166,7 +186,9 @@ module.exports = {
166186
return;
167187
}
168188

169-
if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
189+
// Empty-string aria-label/aria-labelledby/alt provides no accessible
190+
// name — require a non-empty fallback value.
191+
if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
170192
context.report({ node, messageId: 'inputImage' });
171193
}
172194

@@ -177,7 +199,7 @@ module.exports = {
177199
const roleValue = getTextValue(roleAttr);
178200

179201
if (
180-
hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
202+
hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
181203
hasChildren(node) ||
182204
(roleValue && ['presentation', 'none'].includes(roleValue))
183205
) {
@@ -189,7 +211,7 @@ module.exports = {
189211
break;
190212
}
191213
case 'area': {
192-
if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
214+
if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
193215
context.report({ node, messageId: 'areaMissing' });
194216
}
195217

tests/lib/rules/template-require-valid-alt-text.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,53 @@ ruleTester.run('template-require-valid-alt-text', rule, {
5959
'<template><img role={{unless this.altText "presentation"}} alt={{this.altText}}></template>',
6060
],
6161
invalid: [
62+
// Empty-string aria-label / aria-labelledby / alt provides no accessible
63+
// name. These must flag (previously accepted by a presence-only check).
64+
{
65+
code: '<template><input type="image" aria-label="" /></template>',
66+
output: null,
67+
errors: [{ messageId: 'inputImage' }],
68+
},
69+
{
70+
code: '<template><input type="image" aria-labelledby="" /></template>',
71+
output: null,
72+
errors: [{ messageId: 'inputImage' }],
73+
},
74+
{
75+
code: '<template><input type="image" alt="" /></template>',
76+
output: null,
77+
errors: [{ messageId: 'inputImage' }],
78+
},
79+
{
80+
code: '<template><object aria-label=""></object></template>',
81+
output: null,
82+
errors: [{ messageId: 'objectMissing' }],
83+
},
84+
{
85+
code: '<template><object aria-labelledby=""></object></template>',
86+
output: null,
87+
errors: [{ messageId: 'objectMissing' }],
88+
},
89+
{
90+
code: '<template><object title=""></object></template>',
91+
output: null,
92+
errors: [{ messageId: 'objectMissing' }],
93+
},
94+
{
95+
code: '<template><area aria-label=""></template>',
96+
output: null,
97+
errors: [{ messageId: 'areaMissing' }],
98+
},
99+
{
100+
code: '<template><area aria-labelledby=""></template>',
101+
output: null,
102+
errors: [{ messageId: 'areaMissing' }],
103+
},
104+
{
105+
code: '<template><area alt=""></template>',
106+
output: null,
107+
errors: [{ messageId: 'areaMissing' }],
108+
},
62109
{
63110
code: '<template><img src="/test.jpg" /></template>',
64111
output: null,

0 commit comments

Comments
 (0)