Skip to content

Commit 9d0ba71

Browse files
Merge pull request #2730 from johanrd/fix/alt-text-empty-aria-label
BUGFIX: template-require-valid-alt-text — reject empty-string aria-label/labelledby/alt on <input type=image>, <object>, <area>
2 parents 00fbc3b + 5c900c3 commit 9d0ba71

2 files changed

Lines changed: 156 additions & 5 deletions

File tree

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

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const { getStaticAttrValue } = require('../utils/static-attr-value');
2+
13
const REDUNDANT_WORDS = ['image', 'photo', 'picture', 'logo', 'spacer'];
24

35
function findAttr(node, name) {
@@ -8,8 +10,39 @@ function hasAttr(node, name) {
810
return node.attributes?.some((a) => a.name === name);
911
}
1012

11-
function hasAnyAttr(node, names) {
12-
return names.some((name) => hasAttr(node, name));
13+
/**
14+
* Returns true if the named attribute is present with a non-empty, non-whitespace
15+
* static value, OR present with a dynamic (mustache/concat) value. Dynamic values
16+
* are assumed to resolve to a meaningful name at runtime (we can't verify at lint
17+
* time). Static empty-string / whitespace-only values return false — applied to
18+
* alt / aria-label / aria-labelledby / title. The empty-name treatment aligns with
19+
* ACCNAME 1.2 §4.3.2's aria-label step (2D), which normalizes empty/whitespace
20+
* to "no name"; we apply the same normalization to the other fallbacks.
21+
* (Why a name is needed: WCAG SC 4.1.2 — "all user interface components have a
22+
* name and role that can be programmatically determined.")
23+
*
24+
* NOTE: This does not validate that aria-labelledby IDREFs reference existing IDs.
25+
* Rule consumers should layer that check separately if needed.
26+
*/
27+
function hasNonEmptyTextAttr(node, name) {
28+
const attr = findAttr(node, name);
29+
if (!attr?.value) {
30+
return false;
31+
}
32+
// Resolve mustache-literal / single-part concat forms to their static
33+
// string via the shared helper. `aria-label={{""}}` / `aria-label="{{""}}"`
34+
// now normalise to the empty string and are treated the same as the
35+
// text-node empty value.
36+
const resolved = getStaticAttrValue(attr.value);
37+
if (resolved === undefined) {
38+
// Genuinely dynamic — assume truthy (can't verify at lint time).
39+
return true;
40+
}
41+
return resolved.trim() !== '';
42+
}
43+
44+
function hasAnyNonEmptyTextAttr(node, names) {
45+
return names.some((name) => hasNonEmptyTextAttr(node, name));
1346
}
1447

1548
function getTextValue(attr) {
@@ -166,7 +199,9 @@ module.exports = {
166199
return;
167200
}
168201

169-
if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
202+
// Empty-string aria-label/aria-labelledby/alt provides no accessible
203+
// name — require a non-empty fallback value.
204+
if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
170205
context.report({ node, messageId: 'inputImage' });
171206
}
172207

@@ -177,7 +212,7 @@ module.exports = {
177212
const roleValue = getTextValue(roleAttr);
178213

179214
if (
180-
hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
215+
hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
181216
hasChildren(node) ||
182217
(roleValue && ['presentation', 'none'].includes(roleValue))
183218
) {
@@ -189,7 +224,7 @@ module.exports = {
189224
break;
190225
}
191226
case 'area': {
192-
if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
227+
if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
193228
context.report({ node, messageId: 'areaMissing' });
194229
}
195230

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ ruleTester.run('template-require-valid-alt-text', rule, {
1212
'<template><img alt="Company branding" src="/logo.png" /></template>',
1313
'<template><img alt="" src="/decorative.png" /></template>',
1414
'<template><img hidden alt="" /></template>',
15+
// Whitespace-only alt — pin our current behavior. Peer plugins
16+
// (jsx-a11y) accept this; we don't trim before considering "empty alt".
17+
'<template><img alt=" " /></template>',
1518

1619
'<template><img alt="hullo"></template>',
1720
'<template><img alt={{foo}}></template>',
@@ -57,8 +60,89 @@ ruleTester.run('template-require-valid-alt-text', rule, {
5760
'<template><area aria-labelledby="some-alt"></template>',
5861
'<template><area aria-label="some-alt"></template>',
5962
'<template><img role={{unless this.altText "presentation"}} alt={{this.altText}}></template>',
63+
// Mustache-string-literal forms resolve to their static value — a
64+
// non-empty literal supplies an accessible name the same as a text node.
65+
'<template><input type="image" aria-label={{"valid"}} /></template>',
6066
],
6167
invalid: [
68+
// Empty-string aria-label / aria-labelledby / alt provides no accessible
69+
// name, so these must flag.
70+
{
71+
code: '<template><input type="image" aria-label="" /></template>',
72+
output: null,
73+
errors: [{ messageId: 'inputImage' }],
74+
},
75+
{
76+
code: '<template><input type="image" aria-labelledby="" /></template>',
77+
output: null,
78+
errors: [{ messageId: 'inputImage' }],
79+
},
80+
{
81+
code: '<template><input type="image" alt="" /></template>',
82+
output: null,
83+
errors: [{ messageId: 'inputImage' }],
84+
},
85+
{
86+
code: '<template><object aria-label=""></object></template>',
87+
output: null,
88+
errors: [{ messageId: 'objectMissing' }],
89+
},
90+
{
91+
code: '<template><object aria-labelledby=""></object></template>',
92+
output: null,
93+
errors: [{ messageId: 'objectMissing' }],
94+
},
95+
{
96+
code: '<template><object title=""></object></template>',
97+
output: null,
98+
errors: [{ messageId: 'objectMissing' }],
99+
},
100+
{
101+
code: '<template><area aria-label=""></template>',
102+
output: null,
103+
errors: [{ messageId: 'areaMissing' }],
104+
},
105+
{
106+
code: '<template><area aria-labelledby=""></template>',
107+
output: null,
108+
errors: [{ messageId: 'areaMissing' }],
109+
},
110+
{
111+
code: '<template><area alt=""></template>',
112+
output: null,
113+
errors: [{ messageId: 'areaMissing' }],
114+
},
115+
// Whitespace-only values are not valid accessible names per ACCNAME 1.2 §4.3.2 step 2D.
116+
{
117+
code: '<template><input type="image" aria-label=" " /></template>',
118+
output: null,
119+
errors: [{ messageId: 'inputImage' }],
120+
},
121+
{
122+
code: '<template><object aria-labelledby="\n\t" ></object></template>',
123+
output: null,
124+
errors: [{ messageId: 'objectMissing' }],
125+
},
126+
// <area>: title is NOT one of the accepted fallbacks per ACCNAME.
127+
// Only alt / aria-label / aria-labelledby contribute. A whitespace-only
128+
// aria-label should be flagged.
129+
{
130+
code: '<template><area href="/" aria-label=" " /></template>',
131+
output: null,
132+
errors: [{ messageId: 'areaMissing' }],
133+
},
134+
// Mustache-string-literal forms that resolve to an empty string are
135+
// treated the same as the text-node empty value — no accessible name.
136+
{
137+
code: '<template><input type="image" aria-label={{""}} /></template>',
138+
output: null,
139+
errors: [{ messageId: 'inputImage' }],
140+
},
141+
{
142+
code: '<template><input type="image" aria-label="{{""}}" /></template>',
143+
output: null,
144+
errors: [{ messageId: 'inputImage' }],
145+
},
62146
{
63147
code: '<template><img src="/test.jpg" /></template>',
64148
output: null,
@@ -264,6 +348,38 @@ hbsRuleTester.run('template-require-valid-alt-text', rule, {
264348
},
265349
],
266350
},
351+
// HBS parity: empty / whitespace-only accessible-name fallbacks
352+
// should be flagged, mirroring the GTS cases above.
353+
{
354+
code: '<input type="image" aria-label=" " />',
355+
output: null,
356+
errors: [
357+
{
358+
message:
359+
'All <input> elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
360+
},
361+
],
362+
},
363+
{
364+
code: '<object aria-labelledby="\n\t" ></object>',
365+
output: null,
366+
errors: [
367+
{
368+
message:
369+
'Embedded <object> elements must have alternative text by providing inner text, aria-label or aria-labelledby attributes.',
370+
},
371+
],
372+
},
373+
{
374+
code: '<area href="/" aria-label=" " />',
375+
output: null,
376+
errors: [
377+
{
378+
message:
379+
'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
380+
},
381+
],
382+
},
267383
{
268384
code: '<img alt="picture">',
269385
output: null,

0 commit comments

Comments
 (0)