Skip to content

Commit 0172580

Browse files
committed
feat: add template-anchor-has-content
1 parent 414d6d5 commit 0172580

6 files changed

Lines changed: 722 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
258258

259259
| Name                                            | Description | 💼 | 🔧 | 💡 |
260260
| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
261+
| [template-anchor-has-content](docs/rules/template-anchor-has-content.md) | require anchor elements to contain accessible content | | | |
261262
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | |
262263
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
263264
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# ember/template-anchor-has-content
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Requires every `<a href>` anchor to expose a non-empty accessible name to assistive technology.
6+
7+
An anchor with no text, no accessible-name attribute (`aria-label`, `aria-labelledby`, `title`), and no accessible children (e.g. an `<img>` with non-empty `alt`) is rendered by screen readers as an empty link. Users have no way to tell what the link does.
8+
9+
## Rule Details
10+
11+
The rule only inspects plain `<a>` elements that have an `href` attribute. Component invocations (PascalCase, `@arg`, `this.foo`, `foo.bar`, `foo::bar`) are skipped — the rule cannot see through a component's implementation.
12+
13+
For each in-scope anchor the rule computes whether the element exposes an accessible name:
14+
15+
- A non-empty `aria-label`, `aria-labelledby`, or `title` on the anchor itself is an accessible name (any non-static / dynamic value is trusted).
16+
- Static text (including text nested inside child elements) is an accessible name.
17+
- `<img alt="...">` children contribute their `alt` to the name.
18+
- Children with `aria-hidden="true"` (or `{{true}}`) contribute nothing, even if they contain text or `alt`. Valueless / empty-string `aria-hidden` resolves to the default `undefined` per the WAI-ARIA value table and is treated as not-hidden — those children still contribute.
19+
- Dynamic content (`{{@foo}}`, `{{this.foo}}`, `{{#if ...}}`) is treated as opaque: the rule does not flag the anchor because it cannot know what will render.
20+
21+
## Examples
22+
23+
This rule **allows** the following:
24+
25+
```gjs
26+
<template>
27+
<a href="/about">About us</a>
28+
<a href="/x"><span>Profile</span></a>
29+
<a href="/x" aria-label="Close" />
30+
<a href="/x" title="Open menu" />
31+
<a href="/x"><img alt="Search" /></a>
32+
<a href="/x">{{@label}}</a>
33+
<a href="/x"><span aria-hidden>Profile</span></a>
34+
<Link href="/x" />
35+
</template>
36+
```
37+
38+
This rule **forbids** the following:
39+
40+
```gjs
41+
<template>
42+
<a href="/x" />
43+
<a href="/x"></a>
44+
<a href="/x"> </a>
45+
<a href="/x"><span aria-hidden="true">X</span></a>
46+
<a href="/x"><img aria-hidden="true" alt="Search" /></a>
47+
<a href="/x"><img /></a>
48+
<a href="/x" aria-label="" />
49+
</template>
50+
```
51+
52+
## References
53+
54+
- [W3C: Accessible Name and Description Computation (accname)](https://www.w3.org/TR/accname/)
55+
- [MDN: The Anchor element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
56+
- [WCAG 2.4.4 — Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
57+
- [jsx-a11y/anchor-has-content](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/anchor-has-content.md)
58+
- [vuejs-accessibility/anchor-has-content](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/anchor-has-content.md)
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
'use strict';
2+
3+
const { isNativeElement } = require('../utils/is-native-element');
4+
const { getStaticAttrValue } = require('../utils/static-attr-value');
5+
6+
function isDynamicValue(value) {
7+
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
8+
}
9+
10+
// Returns true if the `aria-hidden` attribute is explicitly set to "true"
11+
// (case-insensitive) or mustache-literal `{{true}}` / `{{"true"}}` / the
12+
// quoted-mustache concat equivalents. Per WAI-ARIA 1.2 §6.6 + aria-hidden
13+
// value table, valueless / empty-string `aria-hidden` resolves to the
14+
// default `undefined` — NOT `true` — so those forms do NOT hide the
15+
// element per spec. This aligns with the spec-first decisions in #2717 /
16+
// #19 / #33, and diverges from jsx-a11y's JSX-coercion convention. All
17+
// shape-unwrapping is delegated to the shared `getStaticAttrValue` helper.
18+
function isAriaHiddenTrue(attr) {
19+
if (!attr) {
20+
return false;
21+
}
22+
const resolved = getStaticAttrValue(attr.value);
23+
if (resolved === undefined) {
24+
// Dynamic — can't prove truthy.
25+
return false;
26+
}
27+
return resolved.trim().toLowerCase() === 'true';
28+
}
29+
30+
// True if the anchor itself declares an accessible name via a statically
31+
// non-empty `aria-label`, `aria-labelledby`, or `title`, OR via a dynamic
32+
// value (we can't know at lint time whether a mustache resolves to an empty
33+
// string, so we give the author the benefit of the doubt — matching the
34+
// "skip dynamic" posture used by `template-no-invalid-link-text`).
35+
function hasAccessibleNameAttribute(node) {
36+
const attrs = node.attributes || [];
37+
for (const name of ['aria-label', 'aria-labelledby', 'title']) {
38+
const attr = attrs.find((a) => a.name === name);
39+
if (!attr) {
40+
continue;
41+
}
42+
if (attr.value?.type === 'GlimmerMustacheStatement') {
43+
const resolved = getStaticAttrValue(attr.value);
44+
if (resolved === undefined) {
45+
// Truly dynamic (e.g. `aria-label={{@label}}`) — can't know at lint
46+
// time; give the author the benefit of the doubt.
47+
return true;
48+
}
49+
// Static string literal in mustache, e.g. `aria-label={{""}}`.
50+
// Treat exactly like a plain text value: non-empty means a name exists.
51+
if (resolved.trim().length > 0) {
52+
return true;
53+
}
54+
continue;
55+
}
56+
if (isDynamicValue(attr.value)) {
57+
// GlimmerConcatStatement — treat as dynamic.
58+
return true;
59+
}
60+
if (attr.value?.type === 'GlimmerTextNode') {
61+
// Normalize `&nbsp;` to space before the whitespace check — matches the
62+
// sibling rule `template-no-invalid-link-text`. `aria-label="&nbsp;"`
63+
// is functionally empty for assistive tech (no visual content, no
64+
// announced text) and shouldn't count as an accessible name.
65+
const chars = attr.value.chars.replaceAll('&nbsp;', ' ');
66+
if (chars.trim().length > 0) {
67+
return true;
68+
}
69+
}
70+
}
71+
return false;
72+
}
73+
74+
// Recursively inspect a single child node and report how it would contribute
75+
// to the anchor's accessible name.
76+
// { dynamic: true } — opaque at lint time; treat anchor as labeled.
77+
// { accessible: true } — statically contributes a non-empty name.
78+
// { accessible: false } — contributes nothing (empty text, aria-hidden
79+
// subtree, <img> without non-empty alt, …).
80+
function evaluateChild(child, sourceCode) {
81+
if (child.type === 'GlimmerTextNode') {
82+
const text = child.chars.replaceAll('&nbsp;', ' ').trim();
83+
return { dynamic: false, accessible: text.length > 0 };
84+
}
85+
86+
if (
87+
child.type === 'GlimmerMustacheStatement' ||
88+
child.type === 'GlimmerSubExpression' ||
89+
child.type === 'GlimmerBlockStatement'
90+
) {
91+
// Dynamic content — can't statically tell whether it renders to something.
92+
// Mirror `template-no-invalid-link-text`'s stance and skip.
93+
return { dynamic: true, accessible: false };
94+
}
95+
96+
if (child.type === 'GlimmerElementNode') {
97+
const attrs = child.attributes || [];
98+
const ariaHidden = attrs.find((a) => a.name === 'aria-hidden');
99+
if (isAriaHiddenTrue(ariaHidden)) {
100+
// aria-hidden subtrees contribute nothing, regardless of content.
101+
return { dynamic: false, accessible: false };
102+
}
103+
104+
// HTML boolean `hidden` (§5.4) removes the element from rendering AND
105+
// from the accessibility tree — equivalent to aria-hidden="true" for
106+
// accessible-name purposes. A <span hidden>Backup</span> inside an
107+
// anchor contributes no name at runtime.
108+
if (attrs.some((a) => a.name === 'hidden')) {
109+
return { dynamic: false, accessible: false };
110+
}
111+
112+
// Non-native children (components, custom elements, scope-shadowed tags)
113+
// are opaque — we can't see inside them.
114+
if (!isNativeElement(child, sourceCode)) {
115+
return { dynamic: true, accessible: false };
116+
}
117+
118+
// An <img> child contributes its alt text to the anchor's accessible name.
119+
if (child.tag?.toLowerCase() === 'img') {
120+
const altAttr = attrs.find((a) => a.name === 'alt');
121+
if (!altAttr) {
122+
// Missing alt is a separate a11y concern; treat as no contribution.
123+
return { dynamic: false, accessible: false };
124+
}
125+
if (altAttr.value?.type === 'GlimmerMustacheStatement') {
126+
const resolved = getStaticAttrValue(altAttr.value);
127+
if (resolved === undefined) {
128+
// Truly dynamic (e.g. `alt={{@alt}}`) — trust the author.
129+
return { dynamic: true, accessible: false };
130+
}
131+
// Static string literal in mustache, e.g. `alt={{""}}` or
132+
// `alt={{"Search"}}` — treat exactly like a plain text value.
133+
return { dynamic: false, accessible: resolved.trim().length > 0 };
134+
}
135+
if (isDynamicValue(altAttr.value)) {
136+
// GlimmerConcatStatement — treat as dynamic.
137+
return { dynamic: true, accessible: false };
138+
}
139+
if (altAttr.value?.type === 'GlimmerTextNode') {
140+
// Same `&nbsp;` normalization as hasAccessibleNameAttribute above —
141+
// `<img alt="&nbsp;">` contributes no meaningful name.
142+
const chars = altAttr.value.chars.replaceAll('&nbsp;', ' ');
143+
return { dynamic: false, accessible: chars.trim().length > 0 };
144+
}
145+
return { dynamic: false, accessible: false };
146+
}
147+
148+
// For any other HTML element child, recurse into its children AND its own
149+
// aria-label/aria-labelledby/title (author may label an inner <span>).
150+
if (hasAccessibleNameAttribute(child)) {
151+
return { dynamic: false, accessible: true };
152+
}
153+
154+
return evaluateChildren(child.children || [], sourceCode);
155+
}
156+
157+
return { dynamic: false, accessible: false };
158+
}
159+
160+
function evaluateChildren(children, sourceCode) {
161+
let dynamic = false;
162+
for (const child of children) {
163+
const result = evaluateChild(child, sourceCode);
164+
if (result.accessible) {
165+
return { dynamic: false, accessible: true };
166+
}
167+
if (result.dynamic) {
168+
dynamic = true;
169+
}
170+
}
171+
return { dynamic, accessible: false };
172+
}
173+
174+
/** @type {import('eslint').Rule.RuleModule} */
175+
module.exports = {
176+
meta: {
177+
type: 'problem',
178+
docs: {
179+
description: 'require anchor elements to contain accessible content',
180+
category: 'Accessibility',
181+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-anchor-has-content.md',
182+
templateMode: 'both',
183+
},
184+
fixable: null,
185+
schema: [],
186+
messages: {
187+
anchorHasContent:
188+
'Anchors must have content and the content must be accessible by a screen reader.',
189+
},
190+
},
191+
192+
create(context) {
193+
const sourceCode = context.sourceCode || context.getSourceCode();
194+
return {
195+
GlimmerElementNode(node) {
196+
// Only the native <a> element — in strict GJS, a lowercase tag can be
197+
// shadowed by an in-scope local binding, and components shouldn't be
198+
// validated here. `isNativeElement` combines authoritative html/svg/
199+
// mathml tag lists with scope-shadowing detection.
200+
if (!isNativeElement(node, sourceCode)) {
201+
return;
202+
}
203+
if (node.tag?.toLowerCase() !== 'a') {
204+
return;
205+
}
206+
207+
// Only anchors acting as links (with href) are in scope. An <a> without
208+
// href is covered by `template-link-href-attributes` / not a link.
209+
const attrs = node.attributes || [];
210+
const hasHref = attrs.some((a) => a.name === 'href');
211+
if (!hasHref) {
212+
return;
213+
}
214+
215+
// Skip anchors the author has explicitly hidden — either via the HTML
216+
// `hidden` boolean attribute (element is not rendered at all) or
217+
// `aria-hidden="true"` (element removed from the accessibility tree).
218+
// In both cases, "accessible name of an anchor" is moot.
219+
const hasHidden = attrs.some((a) => a.name === 'hidden');
220+
if (hasHidden) {
221+
return;
222+
}
223+
const ariaHiddenAttr = attrs.find((a) => a.name === 'aria-hidden');
224+
if (isAriaHiddenTrue(ariaHiddenAttr)) {
225+
return;
226+
}
227+
228+
if (hasAccessibleNameAttribute(node)) {
229+
return;
230+
}
231+
232+
const result = evaluateChildren(node.children || [], sourceCode);
233+
if (result.accessible || result.dynamic) {
234+
return;
235+
}
236+
237+
context.report({ node, messageId: 'anchorHasContent' });
238+
},
239+
};
240+
},
241+
};

lib/utils/is-native-element.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,17 @@ const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
2121
* MathML spec registries, reached via the `html-tags` / `svg-tags` /
2222
* `mathml-tag-names` packages). It is NOT the same as:
2323
*
24-
* - "native accessibility" / "widget-ness" — see `interactive-roles.js`
25-
* (aria-query widget taxonomy; an ARIA-tree-semantics question)
26-
* - "native interactive content" / "focus behavior" — see
27-
* `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model
28-
* question about which tags can be nested inside what)
24+
* - "native accessibility" / "widget-ness" — an ARIA-tree-semantics
25+
* question (for example, whether something maps to a widget role)
26+
* - "native interactive content" / "focus behavior" — an HTML content-model
27+
* question about which elements are considered interactive in the spec
2928
* - "natively focusable" / sequential-focus — see HTML §6.6.3
3029
*
3130
* This util answers only: "is this tag a first-class built-in element of one
3231
* of the three markup-language standards, rather than a component invocation
33-
* or a shadowed local binding?" Callers compose it with the other utils
34-
* above when they need a more specific question (see e.g. `template-no-
35-
* noninteractive-tabindex`, which consults both this and
36-
* `html-interactive-content`).
32+
* or a shadowed local binding?" Callers should combine it with whatever
33+
* accessibility, interactivity, or focusability checks they need for more
34+
* specific questions.
3735
*
3836
* Returns false for:
3937
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —

0 commit comments

Comments
 (0)