diff --git a/docs/rules/template-no-empty-headings.md b/docs/rules/template-no-empty-headings.md index 96dbc2a50e..69c07cd083 100644 --- a/docs/rules/template-no-empty-headings.md +++ b/docs/rules/template-no-empty-headings.md @@ -50,6 +50,14 @@ This rule **allows** the following: 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. +## Notes on `aria-hidden` semantics + +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. + +- `aria-hidden="true"` / `aria-hidden={{true}}` / `aria-hidden={{"true"}}` (any case, whitespace-trimmed) → hidden, exempts the heading. +- `aria-hidden="false"` / `aria-hidden={{false}}` / `aria-hidden={{"false"}}` → not hidden, the empty-content check applies. +- `

` / `aria-hidden=""` / `aria-hidden={{""}}` / `aria-hidden={{" "}}` → spec-default `undefined`, the empty-content check applies. + ## References - [WCAG SC 2.4.6 Headings and Labels](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index edb71caae6..456c7a67ee 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -1,5 +1,26 @@ +const { getStaticAttrValue } = require('../utils/static-attr-value'); + const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); +// Aligned with the WAI-ARIA 1.2 [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden) +// value table (`true | false | undefined (default)`): treat only an explicit +// "true" (ASCII case-insensitive, whitespace-trimmed) as hiding the element. +// Valueless `

`, empty-string `aria-hidden=""`, and +// `aria-hidden="false"` all resolve to the default `undefined` / explicit +// false — so the empty-content check still applies. All shape-unwrapping +// (mustache/concat) goes through the shared `getStaticAttrValue` helper. +function isAriaHiddenTruthy(attr) { + if (!attr) { + return false; + } + const resolved = getStaticAttrValue(attr.value); + if (resolved === undefined) { + // Dynamic — can't prove truthy. + return false; + } + return resolved.trim().toLowerCase() === 'true'; +} + function isHidden(node) { if (!node.attributes) { return false; @@ -7,11 +28,7 @@ function isHidden(node) { if (node.attributes.some((a) => a.name === 'hidden')) { return true; } - const ariaHidden = node.attributes.find((a) => a.name === 'aria-hidden'); - if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') { - return true; - } - return false; + return isAriaHiddenTruthy(node.attributes.find((a) => a.name === 'aria-hidden')); } function isComponent(node) { diff --git a/lib/utils/static-attr-value.js b/lib/utils/static-attr-value.js new file mode 100644 index 0000000000..6499c782a9 --- /dev/null +++ b/lib/utils/static-attr-value.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Return the statically-known string value of a Glimmer attribute value node, + * or `undefined` when the value is dynamic (cannot be resolved at lint time). + * + * Unwraps: + * - GlimmerTextNode → chars + * - GlimmerMustacheStatement with a literal path (boolean/string/number) → stringified value + * - GlimmerConcatStatement whose parts are all statically resolvable → joined string + * + * A missing/undefined value (valueless attribute, e.g. ``) + * returns the empty string. Pass `attr.value` — not the attribute itself. + */ +function getStaticAttrValue(value) { + if (value === null || value === undefined) { + return ''; + } + if (value.type === 'GlimmerTextNode') { + return value.chars; + } + if (value.type === 'GlimmerMustacheStatement') { + return extractLiteral(value.path); + } + if (value.type === 'GlimmerConcatStatement') { + const parts = value.parts || []; + let out = ''; + for (const part of parts) { + if (part.type === 'GlimmerTextNode') { + out += part.chars; + continue; + } + if (part.type === 'GlimmerMustacheStatement') { + const literal = extractLiteral(part.path); + if (literal === undefined) { + return undefined; + } + out += literal; + continue; + } + return undefined; + } + return out; + } + return undefined; +} + +function extractLiteral(path) { + if (!path) { + return undefined; + } + if (path.type === 'GlimmerBooleanLiteral') { + return path.value ? 'true' : 'false'; + } + if (path.type === 'GlimmerStringLiteral') { + return path.value; + } + if (path.type === 'GlimmerNumberLiteral') { + return String(path.value); + } + return undefined; +} + +module.exports = { getStaticAttrValue }; diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js index cce6b806da..bc708a9093 100644 --- a/tests/lib/rules/template-no-empty-headings.js +++ b/tests/lib/rules/template-no-empty-headings.js @@ -43,6 +43,24 @@ ruleTester.run('template-no-empty-headings', rule, { '', '', '', + + // Explicit "true" exempts the empty-heading check — author has + // signalled the heading is intentionally hidden from assistive tech. + '', + '', + '', + '', + '', + '', + // Quoted-mustache (GlimmerConcatStatement) forms — `aria-hidden="{{true}}"` + // resolves the same as `aria-hidden={{true}}`. Pin these so future + // refactors don't regress concat handling. + '', + '', + // Whitespace normalization — incidental surrounding whitespace should + // still resolve to "true". + '', + '', ], invalid: [ { @@ -132,5 +150,54 @@ ruleTester.run('template-no-empty-headings', rule, { output: null, errors: [{ messageId: 'emptyHeading' }], }, + + // Explicit falsy aria-hidden does NOT exempt the empty-heading check — + // this is the unambiguous opt-out, no ecosystem position disagrees. + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + // Per the WAI-ARIA 1.2 `aria-hidden` value table + // (https://www.w3.org/TR/wai-aria-1.2/#aria-hidden): valueless / + // empty-string `aria-hidden` resolves to the default `undefined`, + // not `true`. Empty headings with these forms still flag. + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + // Mustache / concat forms that resolve to an empty / whitespace-only + // string — same spec-aligned treatment. + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, ], }); diff --git a/tests/lib/utils/static-attr-value-test.js b/tests/lib/utils/static-attr-value-test.js new file mode 100644 index 0000000000..ea9a4f30c6 --- /dev/null +++ b/tests/lib/utils/static-attr-value-test.js @@ -0,0 +1,129 @@ +'use strict'; + +const { getStaticAttrValue } = require('../../../lib/utils/static-attr-value'); + +describe('getStaticAttrValue', () => { + it('returns empty string for null/undefined (valueless attribute)', () => { + expect(getStaticAttrValue(null)).toBe(''); + expect(getStaticAttrValue(undefined)).toBe(''); + }); + + it('returns chars for GlimmerTextNode', () => { + expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: 'hello' })).toBe('hello'); + expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: '' })).toBe(''); + }); + + it('unwraps GlimmerMustacheStatement with BooleanLiteral', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: true }, + }) + ).toBe('true'); + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: false }, + }) + ).toBe('false'); + }); + + it('unwraps GlimmerMustacheStatement with StringLiteral', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerStringLiteral', value: 'foo' }, + }) + ).toBe('foo'); + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerStringLiteral', value: '' }, + }) + ).toBe(''); + }); + + it('unwraps GlimmerMustacheStatement with NumberLiteral', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerNumberLiteral', value: -1 }, + }) + ).toBe('-1'); + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerNumberLiteral', value: 0 }, + }) + ).toBe('0'); + }); + + it('returns undefined for GlimmerMustacheStatement with a dynamic PathExpression', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerPathExpression', original: 'this.foo' }, + }) + ).toBeUndefined(); + }); + + it('joins GlimmerConcatStatement with only static parts', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { type: 'GlimmerTextNode', chars: 'prefix-' }, + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerStringLiteral', value: 'mid' }, + }, + { type: 'GlimmerTextNode', chars: '-suffix' }, + ], + }) + ).toBe('prefix-mid-suffix'); + }); + + it('joins concat with boolean and number literal parts', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: true }, + }, + ], + }) + ).toBe('true'); + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerNumberLiteral', value: -1 }, + }, + ], + }) + ).toBe('-1'); + }); + + it('returns undefined for GlimmerConcatStatement with a dynamic part', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { type: 'GlimmerTextNode', chars: 'x-' }, + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerPathExpression', original: 'this.foo' }, + }, + ], + }) + ).toBeUndefined(); + }); + + it('returns undefined for an unknown node type', () => { + expect(getStaticAttrValue({ type: 'GlimmerSubExpression' })).toBeUndefined(); + }); +});