Skip to content

Commit b5cb4fd

Browse files
committed
feat: add template-anchor-has-content
Ports jsx-a11y/anchor-has-content and vuejs-accessibility/anchor-has-content to Ember templates. Requires every <a href> to expose a non-empty accessible name so screen readers announce something meaningful. An anchor is flagged when: - It is empty, whitespace-only, or has aria-label="" / aria-labelledby="" / title="" on itself. - Every child contributes nothing to the accessible name: aria-hidden subtrees, <img> with no alt or empty alt, <img aria-hidden> even with alt (hidden subtrees don't surface alt). The rule is permissive about opacity: - Dynamic content ({{@foo}}, {{this.x}}, {{#if ...}}) is trusted — we cannot statically tell what it renders. - Component invocations as children (PascalCase, @arg, this.x, foo.bar, foo::bar) are treated as opaque. - Component invocations at the <a> position are ignored (only plain <a> is in scope). - Anchors without href are left alone — covered by template-link-href-attributes. - A dynamic aria-label / aria-labelledby / title value is accepted. Scope decision matches existing rule precedent: - href-gating mirrors the "only interactive anchors" treatment in template-no-invalid-interactive. - Component detection inlines the pattern from template-no-invalid- interactive.js:184 (lib/utils/is-component-invocation.js is not yet on master). - Dynamic-value tolerance mirrors template-no-invalid-link-text. Not added to template-lint-migration — opt-in. Closes the M1 finding in the Phase 3 audit for anchor-has-content (audit/phase3/anchor-has-content).
1 parent 24882a3 commit b5cb4fd

4 files changed

Lines changed: 467 additions & 0 deletions

File tree

README.md

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

229229
| Name                                            | Description | 💼 | 🔧 | 💡 |
230230
| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
231+
| [template-anchor-has-content](docs/rules/template-anchor-has-content.md) | require anchor elements to contain accessible content | | | |
231232
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | |
232233
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
233234
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
- `aria-hidden` children contribute nothing, even if they contain text or `alt`.
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+
<Link href="/x" />
34+
</template>
35+
```
36+
37+
This rule **forbids** the following:
38+
39+
```gjs
40+
<template>
41+
<a href="/x" />
42+
<a href="/x"></a>
43+
<a href="/x"> </a>
44+
<a href="/x"><span aria-hidden>X</span></a>
45+
<a href="/x"><img aria-hidden alt="Search" /></a>
46+
<a href="/x"><img /></a>
47+
<a href="/x" aria-label="" />
48+
</template>
49+
```
50+
51+
## References
52+
53+
- [W3C: Accessible Name and Description Computation (accname)](https://www.w3.org/TR/accname/)
54+
- [MDN: The Anchor element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
55+
- [WCAG 2.4.4 — Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
56+
- [jsx-a11y/anchor-has-content](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/anchor-has-content.md)
57+
- [vuejs-accessibility/anchor-has-content](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/anchor-has-content.md)
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Matches a tag string that is a component invocation rather than a plain
2+
// HTML element: PascalCase (`Foo`), argument-invocation (`@foo`), path on
3+
// `this.` (`this.foo`), dotted path (`foo.bar`), or named-block-style
4+
// `foo::bar`. Keep this mirrored with the inline pattern in
5+
// lib/rules/template-no-invalid-interactive.js until a shared utility lands.
6+
function isComponentInvocation(tag) {
7+
if (!tag) {
8+
return false;
9+
}
10+
return (
11+
/^[A-Z]/.test(tag) ||
12+
tag.startsWith('@') ||
13+
tag.startsWith('this.') ||
14+
tag.includes('.') ||
15+
tag.includes('::')
16+
);
17+
}
18+
19+
function isDynamicValue(value) {
20+
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
21+
}
22+
23+
// Returns true if the `aria-hidden` attribute is effectively truthy. Mirrors
24+
// the jsx-a11y/vue-a11y convention: valueless (`aria-hidden`), string `"true"`,
25+
// or `{{true}}` all hide the element from the a11y tree. Dynamic values are
26+
// treated as "hidden" only when the developer explicitly passes boolean true;
27+
// anything we cannot statically resolve falls through as not-hidden so we
28+
// don't silently swallow meaningful content.
29+
function isAriaHiddenTrue(attr) {
30+
if (!attr) {
31+
return false;
32+
}
33+
// Valueless attribute (e.g. `<span aria-hidden />`) parses with no value.
34+
if (attr.value === undefined || attr.value === null) {
35+
return true;
36+
}
37+
if (attr.value.type === 'GlimmerTextNode') {
38+
const chars = attr.value.chars.trim().toLowerCase();
39+
// HTML parses bare `aria-hidden` as `aria-hidden=""`; treat empty as true
40+
// to mirror the valueless shape above.
41+
return chars === '' || chars === 'true';
42+
}
43+
if (attr.value.type === 'GlimmerMustacheStatement') {
44+
const path = attr.value.path;
45+
if (path?.type === 'GlimmerBooleanLiteral') {
46+
return path.value === true;
47+
}
48+
if (path?.type === 'GlimmerStringLiteral') {
49+
return path.value.trim().toLowerCase() === 'true';
50+
}
51+
}
52+
return false;
53+
}
54+
55+
// True if the anchor itself declares an accessible name via a statically
56+
// non-empty `aria-label`, `aria-labelledby`, or `title`, OR via a dynamic
57+
// value (we can't know at lint time whether a mustache resolves to an empty
58+
// string, so we give the author the benefit of the doubt — matching the
59+
// "skip dynamic" posture used by `template-no-invalid-link-text`).
60+
function hasAccessibleNameAttribute(node) {
61+
const attrs = node.attributes || [];
62+
for (const name of ['aria-label', 'aria-labelledby', 'title']) {
63+
const attr = attrs.find((a) => a.name === name);
64+
if (!attr) {
65+
continue;
66+
}
67+
if (isDynamicValue(attr.value)) {
68+
return true;
69+
}
70+
if (attr.value?.type === 'GlimmerTextNode' && attr.value.chars.trim().length > 0) {
71+
return true;
72+
}
73+
}
74+
return false;
75+
}
76+
77+
// Recursively inspect a single child node and report how it would contribute
78+
// to the anchor's accessible name.
79+
// { dynamic: true } — opaque at lint time; treat anchor as labeled.
80+
// { accessible: true } — statically contributes a non-empty name.
81+
// { accessible: false } — contributes nothing (empty text, aria-hidden
82+
// subtree, <img> without non-empty alt, …).
83+
function evaluateChild(child) {
84+
if (child.type === 'GlimmerTextNode') {
85+
const text = child.chars.replaceAll('&nbsp;', ' ').trim();
86+
return { dynamic: false, accessible: text.length > 0 };
87+
}
88+
89+
if (
90+
child.type === 'GlimmerMustacheStatement' ||
91+
child.type === 'GlimmerSubExpression' ||
92+
child.type === 'GlimmerBlockStatement'
93+
) {
94+
// Dynamic content — can't statically tell whether it renders to something.
95+
// Mirror `template-no-invalid-link-text`'s stance and skip.
96+
return { dynamic: true, accessible: false };
97+
}
98+
99+
if (child.type === 'GlimmerElementNode') {
100+
const attrs = child.attributes || [];
101+
const ariaHidden = attrs.find((a) => a.name === 'aria-hidden');
102+
if (isAriaHiddenTrue(ariaHidden)) {
103+
// aria-hidden subtrees contribute nothing, regardless of content.
104+
return { dynamic: false, accessible: false };
105+
}
106+
107+
// Component invocations are opaque — we can't see inside them.
108+
if (isComponentInvocation(child.tag)) {
109+
return { dynamic: true, accessible: false };
110+
}
111+
112+
// An <img> child contributes its alt text to the anchor's accessible name.
113+
if (child.tag?.toLowerCase() === 'img') {
114+
const altAttr = attrs.find((a) => a.name === 'alt');
115+
if (!altAttr) {
116+
// Missing alt is a separate a11y concern; treat as no contribution.
117+
return { dynamic: false, accessible: false };
118+
}
119+
if (isDynamicValue(altAttr.value)) {
120+
return { dynamic: true, accessible: false };
121+
}
122+
if (altAttr.value?.type === 'GlimmerTextNode') {
123+
return { dynamic: false, accessible: altAttr.value.chars.trim().length > 0 };
124+
}
125+
return { dynamic: false, accessible: false };
126+
}
127+
128+
// For any other HTML element child, recurse into its children AND its own
129+
// aria-label/aria-labelledby/title (author may label an inner <span>).
130+
if (hasAccessibleNameAttribute(child)) {
131+
return { dynamic: false, accessible: true };
132+
}
133+
134+
return evaluateChildren(child.children || []);
135+
}
136+
137+
return { dynamic: false, accessible: false };
138+
}
139+
140+
function evaluateChildren(children) {
141+
let dynamic = false;
142+
for (const child of children) {
143+
const result = evaluateChild(child);
144+
if (result.accessible) {
145+
return { dynamic: false, accessible: true };
146+
}
147+
if (result.dynamic) {
148+
dynamic = true;
149+
}
150+
}
151+
return { dynamic, accessible: false };
152+
}
153+
154+
/** @type {import('eslint').Rule.RuleModule} */
155+
module.exports = {
156+
meta: {
157+
type: 'problem',
158+
docs: {
159+
description: 'require anchor elements to contain accessible content',
160+
category: 'Accessibility',
161+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-anchor-has-content.md',
162+
templateMode: 'both',
163+
},
164+
schema: [],
165+
messages: {
166+
anchorHasContent:
167+
'Anchors must have content and the content must be accessible by a screen reader.',
168+
},
169+
},
170+
171+
create(context) {
172+
return {
173+
GlimmerElementNode(node) {
174+
if (node.tag !== 'a') {
175+
return;
176+
}
177+
178+
// Only anchors acting as links (with href) are in scope. An <a> without
179+
// href is covered by `template-link-href-attributes` / not a link.
180+
const hasHref = (node.attributes || []).some((a) => a.name === 'href');
181+
if (!hasHref) {
182+
return;
183+
}
184+
185+
if (hasAccessibleNameAttribute(node)) {
186+
return;
187+
}
188+
189+
const result = evaluateChildren(node.children || []);
190+
if (result.accessible || result.dynamic) {
191+
return;
192+
}
193+
194+
context.report({ node, messageId: 'anchorHasContent' });
195+
},
196+
};
197+
},
198+
};

0 commit comments

Comments
 (0)