Skip to content

Commit b6e78e8

Browse files
Merge pull request ember-cli#2717 from johanrd/fix/heading-accept-boolean-aria-hidden
BUGFIX: template-no-empty-headings — recognize boolean aria-hidden
2 parents f63750f + 7911cf5 commit b6e78e8

5 files changed

Lines changed: 290 additions & 5 deletions

File tree

docs/rules/template-no-empty-headings.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ This rule **allows** the following:
5050

5151
If violations are found, remediation should be planned to ensure text content is present and visible and/or screen-reader accessible. Setting `aria-hidden="false"` or removing `hidden` attributes from the element(s) containing heading text may serve as a quickfix.
5252

53+
## Notes on `aria-hidden` semantics
54+
55+
This rule follows [WAI-ARIA 1.2 §`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden) verbatim: only an explicit truthy value hides the element. Ambiguous shapes — valueless `aria-hidden`, empty string, and mustache literals that resolve to an empty / whitespace-only string — all resolve to the default `undefined` and do NOT exempt the heading from the empty-content check.
56+
57+
- `aria-hidden="true"` / `aria-hidden={{true}}` / `aria-hidden={{"true"}}` (any case, whitespace-trimmed) → hidden, exempts the heading.
58+
- `aria-hidden="false"` / `aria-hidden={{false}}` / `aria-hidden={{"false"}}` → not hidden, the empty-content check applies.
59+
- `<h1 aria-hidden>` / `aria-hidden=""` / `aria-hidden={{""}}` / `aria-hidden={{" "}}` → spec-default `undefined`, the empty-content check applies.
60+
5361
## References
5462

5563
- [WCAG SC 2.4.6 Headings and Labels](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html)

lib/rules/template-no-empty-headings.js

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1+
const { getStaticAttrValue } = require('../utils/static-attr-value');
2+
13
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
24

5+
// Aligned with the WAI-ARIA 1.2 [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)
6+
// value table (`true | false | undefined (default)`): treat only an explicit
7+
// "true" (ASCII case-insensitive, whitespace-trimmed) as hiding the element.
8+
// Valueless `<h1 aria-hidden>`, empty-string `aria-hidden=""`, and
9+
// `aria-hidden="false"` all resolve to the default `undefined` / explicit
10+
// false — so the empty-content check still applies. All shape-unwrapping
11+
// (mustache/concat) goes through the shared `getStaticAttrValue` helper.
12+
function isAriaHiddenTruthy(attr) {
13+
if (!attr) {
14+
return false;
15+
}
16+
const resolved = getStaticAttrValue(attr.value);
17+
if (resolved === undefined) {
18+
// Dynamic — can't prove truthy.
19+
return false;
20+
}
21+
return resolved.trim().toLowerCase() === 'true';
22+
}
23+
324
function isHidden(node) {
425
if (!node.attributes) {
526
return false;
627
}
728
if (node.attributes.some((a) => a.name === 'hidden')) {
829
return true;
930
}
10-
const ariaHidden = node.attributes.find((a) => a.name === 'aria-hidden');
11-
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
12-
return true;
13-
}
14-
return false;
31+
return isAriaHiddenTruthy(node.attributes.find((a) => a.name === 'aria-hidden'));
1532
}
1633

1734
function isComponent(node) {

lib/utils/static-attr-value.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
/**
4+
* Return the statically-known string value of a Glimmer attribute value node,
5+
* or `undefined` when the value is dynamic (cannot be resolved at lint time).
6+
*
7+
* Unwraps:
8+
* - GlimmerTextNode → chars
9+
* - GlimmerMustacheStatement with a literal path (boolean/string/number) → stringified value
10+
* - GlimmerConcatStatement whose parts are all statically resolvable → joined string
11+
*
12+
* A missing/undefined value (valueless attribute, e.g. `<input disabled>`)
13+
* returns the empty string. Pass `attr.value` — not the attribute itself.
14+
*/
15+
function getStaticAttrValue(value) {
16+
if (value === null || value === undefined) {
17+
return '';
18+
}
19+
if (value.type === 'GlimmerTextNode') {
20+
return value.chars;
21+
}
22+
if (value.type === 'GlimmerMustacheStatement') {
23+
return extractLiteral(value.path);
24+
}
25+
if (value.type === 'GlimmerConcatStatement') {
26+
const parts = value.parts || [];
27+
let out = '';
28+
for (const part of parts) {
29+
if (part.type === 'GlimmerTextNode') {
30+
out += part.chars;
31+
continue;
32+
}
33+
if (part.type === 'GlimmerMustacheStatement') {
34+
const literal = extractLiteral(part.path);
35+
if (literal === undefined) {
36+
return undefined;
37+
}
38+
out += literal;
39+
continue;
40+
}
41+
return undefined;
42+
}
43+
return out;
44+
}
45+
return undefined;
46+
}
47+
48+
function extractLiteral(path) {
49+
if (!path) {
50+
return undefined;
51+
}
52+
if (path.type === 'GlimmerBooleanLiteral') {
53+
return path.value ? 'true' : 'false';
54+
}
55+
if (path.type === 'GlimmerStringLiteral') {
56+
return path.value;
57+
}
58+
if (path.type === 'GlimmerNumberLiteral') {
59+
return String(path.value);
60+
}
61+
return undefined;
62+
}
63+
64+
module.exports = { getStaticAttrValue };

tests/lib/rules/template-no-empty-headings.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ ruleTester.run('template-no-empty-headings', rule, {
4343
'<template><h1><this.Heading /></h1></template>',
4444
'<template><h2><@heading /></h2></template>',
4545
'<template><h3><ns.Heading /></h3></template>',
46+
47+
// Explicit "true" exempts the empty-heading check — author has
48+
// signalled the heading is intentionally hidden from assistive tech.
49+
'<template><h1 aria-hidden={{true}}></h1></template>',
50+
'<template><h1 aria-hidden="true">Visible to sighted only</h1></template>',
51+
'<template><h1 aria-hidden="TRUE"></h1></template>',
52+
'<template><h1 aria-hidden="True"></h1></template>',
53+
'<template><h1 aria-hidden={{"TRUE"}}></h1></template>',
54+
'<template><h1 aria-hidden={{"True"}}></h1></template>',
55+
// Quoted-mustache (GlimmerConcatStatement) forms — `aria-hidden="{{true}}"`
56+
// resolves the same as `aria-hidden={{true}}`. Pin these so future
57+
// refactors don't regress concat handling.
58+
'<template><h1 aria-hidden="{{true}}"></h1></template>',
59+
'<template><h1 aria-hidden="{{"true"}}"></h1></template>',
60+
// Whitespace normalization — incidental surrounding whitespace should
61+
// still resolve to "true".
62+
'<template><h1 aria-hidden={{" true "}}></h1></template>',
63+
'<template><h1 aria-hidden=" true "></h1></template>',
4664
],
4765
invalid: [
4866
{
@@ -132,5 +150,54 @@ ruleTester.run('template-no-empty-headings', rule, {
132150
output: null,
133151
errors: [{ messageId: 'emptyHeading' }],
134152
},
153+
154+
// Explicit falsy aria-hidden does NOT exempt the empty-heading check —
155+
// this is the unambiguous opt-out, no ecosystem position disagrees.
156+
{
157+
code: '<template><h1 aria-hidden="false"></h1></template>',
158+
output: null,
159+
errors: [{ messageId: 'emptyHeading' }],
160+
},
161+
{
162+
code: '<template><h1 aria-hidden={{false}}></h1></template>',
163+
output: null,
164+
errors: [{ messageId: 'emptyHeading' }],
165+
},
166+
{
167+
code: '<template><h1 aria-hidden={{"false"}}></h1></template>',
168+
output: null,
169+
errors: [{ messageId: 'emptyHeading' }],
170+
},
171+
// Per the WAI-ARIA 1.2 `aria-hidden` value table
172+
// (https://www.w3.org/TR/wai-aria-1.2/#aria-hidden): valueless /
173+
// empty-string `aria-hidden` resolves to the default `undefined`,
174+
// not `true`. Empty headings with these forms still flag.
175+
{
176+
code: '<template><h1 aria-hidden></h1></template>',
177+
output: null,
178+
errors: [{ messageId: 'emptyHeading' }],
179+
},
180+
{
181+
code: '<template><h1 aria-hidden=""></h1></template>',
182+
output: null,
183+
errors: [{ messageId: 'emptyHeading' }],
184+
},
185+
// Mustache / concat forms that resolve to an empty / whitespace-only
186+
// string — same spec-aligned treatment.
187+
{
188+
code: '<template><h1 aria-hidden={{""}}></h1></template>',
189+
output: null,
190+
errors: [{ messageId: 'emptyHeading' }],
191+
},
192+
{
193+
code: '<template><h1 aria-hidden="{{""}}"></h1></template>',
194+
output: null,
195+
errors: [{ messageId: 'emptyHeading' }],
196+
},
197+
{
198+
code: '<template><h1 aria-hidden={{" "}}></h1></template>',
199+
output: null,
200+
errors: [{ messageId: 'emptyHeading' }],
201+
},
135202
],
136203
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use strict';
2+
3+
const { getStaticAttrValue } = require('../../../lib/utils/static-attr-value');
4+
5+
describe('getStaticAttrValue', () => {
6+
it('returns empty string for null/undefined (valueless attribute)', () => {
7+
expect(getStaticAttrValue(null)).toBe('');
8+
expect(getStaticAttrValue(undefined)).toBe('');
9+
});
10+
11+
it('returns chars for GlimmerTextNode', () => {
12+
expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: 'hello' })).toBe('hello');
13+
expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: '' })).toBe('');
14+
});
15+
16+
it('unwraps GlimmerMustacheStatement with BooleanLiteral', () => {
17+
expect(
18+
getStaticAttrValue({
19+
type: 'GlimmerMustacheStatement',
20+
path: { type: 'GlimmerBooleanLiteral', value: true },
21+
})
22+
).toBe('true');
23+
expect(
24+
getStaticAttrValue({
25+
type: 'GlimmerMustacheStatement',
26+
path: { type: 'GlimmerBooleanLiteral', value: false },
27+
})
28+
).toBe('false');
29+
});
30+
31+
it('unwraps GlimmerMustacheStatement with StringLiteral', () => {
32+
expect(
33+
getStaticAttrValue({
34+
type: 'GlimmerMustacheStatement',
35+
path: { type: 'GlimmerStringLiteral', value: 'foo' },
36+
})
37+
).toBe('foo');
38+
expect(
39+
getStaticAttrValue({
40+
type: 'GlimmerMustacheStatement',
41+
path: { type: 'GlimmerStringLiteral', value: '' },
42+
})
43+
).toBe('');
44+
});
45+
46+
it('unwraps GlimmerMustacheStatement with NumberLiteral', () => {
47+
expect(
48+
getStaticAttrValue({
49+
type: 'GlimmerMustacheStatement',
50+
path: { type: 'GlimmerNumberLiteral', value: -1 },
51+
})
52+
).toBe('-1');
53+
expect(
54+
getStaticAttrValue({
55+
type: 'GlimmerMustacheStatement',
56+
path: { type: 'GlimmerNumberLiteral', value: 0 },
57+
})
58+
).toBe('0');
59+
});
60+
61+
it('returns undefined for GlimmerMustacheStatement with a dynamic PathExpression', () => {
62+
expect(
63+
getStaticAttrValue({
64+
type: 'GlimmerMustacheStatement',
65+
path: { type: 'GlimmerPathExpression', original: 'this.foo' },
66+
})
67+
).toBeUndefined();
68+
});
69+
70+
it('joins GlimmerConcatStatement with only static parts', () => {
71+
expect(
72+
getStaticAttrValue({
73+
type: 'GlimmerConcatStatement',
74+
parts: [
75+
{ type: 'GlimmerTextNode', chars: 'prefix-' },
76+
{
77+
type: 'GlimmerMustacheStatement',
78+
path: { type: 'GlimmerStringLiteral', value: 'mid' },
79+
},
80+
{ type: 'GlimmerTextNode', chars: '-suffix' },
81+
],
82+
})
83+
).toBe('prefix-mid-suffix');
84+
});
85+
86+
it('joins concat with boolean and number literal parts', () => {
87+
expect(
88+
getStaticAttrValue({
89+
type: 'GlimmerConcatStatement',
90+
parts: [
91+
{
92+
type: 'GlimmerMustacheStatement',
93+
path: { type: 'GlimmerBooleanLiteral', value: true },
94+
},
95+
],
96+
})
97+
).toBe('true');
98+
expect(
99+
getStaticAttrValue({
100+
type: 'GlimmerConcatStatement',
101+
parts: [
102+
{
103+
type: 'GlimmerMustacheStatement',
104+
path: { type: 'GlimmerNumberLiteral', value: -1 },
105+
},
106+
],
107+
})
108+
).toBe('-1');
109+
});
110+
111+
it('returns undefined for GlimmerConcatStatement with a dynamic part', () => {
112+
expect(
113+
getStaticAttrValue({
114+
type: 'GlimmerConcatStatement',
115+
parts: [
116+
{ type: 'GlimmerTextNode', chars: 'x-' },
117+
{
118+
type: 'GlimmerMustacheStatement',
119+
path: { type: 'GlimmerPathExpression', original: 'this.foo' },
120+
},
121+
],
122+
})
123+
).toBeUndefined();
124+
});
125+
126+
it('returns undefined for an unknown node type', () => {
127+
expect(getStaticAttrValue({ type: 'GlimmerSubExpression' })).toBeUndefined();
128+
});
129+
});

0 commit comments

Comments
 (0)