Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le

| Name                                            | Description | 💼 | 🔧 | 💡 |
| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
| [template-anchor-has-content](docs/rules/template-anchor-has-content.md) | require anchor elements to contain accessible content | | | |
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | |
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
Expand Down
58 changes: 58 additions & 0 deletions docs/rules/template-anchor-has-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ember/template-anchor-has-content

<!-- end auto-generated rule header -->

Requires every `<a href>` anchor to expose a non-empty accessible name to assistive technology.

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.

## Rule Details

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.

For each in-scope anchor the rule computes whether the element exposes an accessible name:

- A non-empty `aria-label`, `aria-labelledby`, or `title` on the anchor itself is an accessible name (any non-static / dynamic value is trusted).
- Static text (including text nested inside child elements) is an accessible name.
- `<img alt="...">` children contribute their `alt` to the name.
- 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.
- Dynamic content (`{{@foo}}`, `{{this.foo}}`, `{{#if ...}}`) is treated as opaque: the rule does not flag the anchor because it cannot know what will render.
Comment thread
johanrd marked this conversation as resolved.

## Examples

This rule **allows** the following:

```gjs
<template>
<a href="/about">About us</a>
<a href="/x"><span>Profile</span></a>
<a href="/x" aria-label="Close" />
<a href="/x" title="Open menu" />
<a href="/x"><img alt="Search" /></a>
<a href="/x">{{@label}}</a>
<a href="/x"><span aria-hidden>Profile</span></a>
<Link href="/x" />
</template>
```

This rule **forbids** the following:

```gjs
<template>
<a href="/x" />
<a href="/x"></a>
<a href="/x"> </a>
<a href="/x"><span aria-hidden="true">X</span></a>
<a href="/x"><img aria-hidden="true" alt="Search" /></a>
<a href="/x"><img /></a>
<a href="/x" aria-label="" />
</template>
```

## References

- [W3C: Accessible Name and Description Computation (accname)](https://www.w3.org/TR/accname/)
- [MDN: The Anchor element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
- [WCAG 2.4.4 — Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
- [jsx-a11y/anchor-has-content](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/anchor-has-content.md)
- [vuejs-accessibility/anchor-has-content](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/anchor-has-content.md)
241 changes: 241 additions & 0 deletions lib/rules/template-anchor-has-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
'use strict';

const { isNativeElement } = require('../utils/is-native-element');
const { getStaticAttrValue } = require('../utils/static-attr-value');

function isDynamicValue(value) {
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
}

// Returns true if the `aria-hidden` attribute is explicitly set to "true"
// (case-insensitive) or mustache-literal `{{true}}` / `{{"true"}}` / the
// quoted-mustache concat equivalents. Per WAI-ARIA 1.2 §6.6 + aria-hidden
// value table, valueless / empty-string `aria-hidden` resolves to the
// default `undefined` — NOT `true` — so those forms do NOT hide the
// element per spec. This aligns with the spec-first decisions in #2717 /
// #19 / #33, and diverges from jsx-a11y's JSX-coercion convention. All
// shape-unwrapping is delegated to the shared `getStaticAttrValue` helper.
function isAriaHiddenTrue(attr) {
if (!attr) {
return false;
}
const resolved = getStaticAttrValue(attr.value);
if (resolved === undefined) {
// Dynamic — can't prove truthy.
return false;
}
Comment thread
johanrd marked this conversation as resolved.
return resolved.trim().toLowerCase() === 'true';
Comment on lines +22 to +27
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved.trim() assumes getStaticAttrValue always returns a string when it can resolve a value. If it can return booleans for mustache literals (e.g. {{true}} / {{false}}) or null for valueless attributes, this will throw at runtime. Making this robust (e.g., explicitly handling resolved === true, resolved === false, and only calling .trim() when typeof resolved === 'string') will prevent rule crashes on edge-case AST/value shapes.

Copilot uses AI. Check for mistakes.
}

// True if the anchor itself declares an accessible name via a statically
// non-empty `aria-label`, `aria-labelledby`, or `title`, OR via a dynamic
// value (we can't know at lint time whether a mustache resolves to an empty
// string, so we give the author the benefit of the doubt — matching the
// "skip dynamic" posture used by `template-no-invalid-link-text`).
function hasAccessibleNameAttribute(node) {
const attrs = node.attributes || [];
for (const name of ['aria-label', 'aria-labelledby', 'title']) {
const attr = attrs.find((a) => a.name === name);
if (!attr) {
continue;
}
if (attr.value?.type === 'GlimmerMustacheStatement') {
const resolved = getStaticAttrValue(attr.value);
if (resolved === undefined) {
// Truly dynamic (e.g. `aria-label={{@label}}`) — can't know at lint
// time; give the author the benefit of the doubt.
return true;
}
// Static string literal in mustache, e.g. `aria-label={{""}}`.
// Treat exactly like a plain text value: non-empty means a name exists.
if (resolved.trim().length > 0) {
return true;
}
continue;
}
if (isDynamicValue(attr.value)) {
// GlimmerConcatStatement — treat as dynamic.
return true;
}
if (attr.value?.type === 'GlimmerTextNode') {
// Normalize `&nbsp;` to space before the whitespace check — matches the
// sibling rule `template-no-invalid-link-text`. `aria-label="&nbsp;"`
// is functionally empty for assistive tech (no visual content, no
// announced text) and shouldn't count as an accessible name.
const chars = attr.value.chars.replaceAll('&nbsp;', ' ');
if (chars.trim().length > 0) {
return true;
}
}
Comment thread
johanrd marked this conversation as resolved.
}
return false;
Comment thread
johanrd marked this conversation as resolved.
}

// Recursively inspect a single child node and report how it would contribute
// to the anchor's accessible name.
// { dynamic: true } — opaque at lint time; treat anchor as labeled.
// { accessible: true } — statically contributes a non-empty name.
// { accessible: false } — contributes nothing (empty text, aria-hidden
// subtree, <img> without non-empty alt, …).
function evaluateChild(child, sourceCode) {
if (child.type === 'GlimmerTextNode') {
const text = child.chars.replaceAll('&nbsp;', ' ').trim();
return { dynamic: false, accessible: text.length > 0 };
}

if (
child.type === 'GlimmerMustacheStatement' ||
child.type === 'GlimmerSubExpression' ||
child.type === 'GlimmerBlockStatement'
) {
// Dynamic content — can't statically tell whether it renders to something.
// Mirror `template-no-invalid-link-text`'s stance and skip.
return { dynamic: true, accessible: false };
}

if (child.type === 'GlimmerElementNode') {
const attrs = child.attributes || [];
const ariaHidden = attrs.find((a) => a.name === 'aria-hidden');
if (isAriaHiddenTrue(ariaHidden)) {
// aria-hidden subtrees contribute nothing, regardless of content.
return { dynamic: false, accessible: false };
}

Comment thread
johanrd marked this conversation as resolved.
// HTML boolean `hidden` (§5.4) removes the element from rendering AND
// from the accessibility tree — equivalent to aria-hidden="true" for
// accessible-name purposes. A <span hidden>Backup</span> inside an
// anchor contributes no name at runtime.
if (attrs.some((a) => a.name === 'hidden')) {
return { dynamic: false, accessible: false };
}
Comment on lines +104 to +110
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The child hidden handling treats any presence of a hidden attribute as meaning “definitely hidden”, but in Ember templates hidden={{false}} commonly results in the attribute being omitted at runtime (meaning the element is visible and its text should contribute to the anchor’s accessible name). This can cause false positives (flagging an anchor as empty even though it has visible child text). Consider resolving hidden similarly to aria-hidden: only treat it as hidden when it’s statically true (e.g. valueless hidden, or mustache-literal {{true}} / {{"true"}} / other statically-known truthy forms), and treat truly dynamic values as “unknown” (likely best as { dynamic: true } so the anchor isn’t flagged).

Copilot uses AI. Check for mistakes.

// Non-native children (components, custom elements, scope-shadowed tags)
// are opaque — we can't see inside them.
if (!isNativeElement(child, sourceCode)) {
return { dynamic: true, accessible: false };
}

// An <img> child contributes its alt text to the anchor's accessible name.
if (child.tag?.toLowerCase() === 'img') {
const altAttr = attrs.find((a) => a.name === 'alt');
if (!altAttr) {
// Missing alt is a separate a11y concern; treat as no contribution.
return { dynamic: false, accessible: false };
}
if (altAttr.value?.type === 'GlimmerMustacheStatement') {
const resolved = getStaticAttrValue(altAttr.value);
if (resolved === undefined) {
// Truly dynamic (e.g. `alt={{@alt}}`) — trust the author.
return { dynamic: true, accessible: false };
}
// Static string literal in mustache, e.g. `alt={{""}}` or
// `alt={{"Search"}}` — treat exactly like a plain text value.
return { dynamic: false, accessible: resolved.trim().length > 0 };
}
if (isDynamicValue(altAttr.value)) {
// GlimmerConcatStatement — treat as dynamic.
return { dynamic: true, accessible: false };
}
if (altAttr.value?.type === 'GlimmerTextNode') {
// Same `&nbsp;` normalization as hasAccessibleNameAttribute above —
// `<img alt="&nbsp;">` contributes no meaningful name.
const chars = altAttr.value.chars.replaceAll('&nbsp;', ' ');
return { dynamic: false, accessible: chars.trim().length > 0 };
}
return { dynamic: false, accessible: false };
Comment thread
johanrd marked this conversation as resolved.
}

// For any other HTML element child, recurse into its children AND its own
// aria-label/aria-labelledby/title (author may label an inner <span>).
if (hasAccessibleNameAttribute(child)) {
return { dynamic: false, accessible: true };
}

return evaluateChildren(child.children || [], sourceCode);
}

return { dynamic: false, accessible: false };
}

function evaluateChildren(children, sourceCode) {
let dynamic = false;
for (const child of children) {
const result = evaluateChild(child, sourceCode);
if (result.accessible) {
return { dynamic: false, accessible: true };
}
if (result.dynamic) {
dynamic = true;
}
}
return { dynamic, accessible: false };
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require anchor elements to contain accessible content',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-anchor-has-content.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
anchorHasContent:
'Anchors must have content and the content must be accessible by a screen reader.',
},
},
Comment thread
johanrd marked this conversation as resolved.

create(context) {
const sourceCode = context.sourceCode || context.getSourceCode();
return {
GlimmerElementNode(node) {
// Only the native <a> element — in strict GJS, a lowercase tag can be
// shadowed by an in-scope local binding, and components shouldn't be
// validated here. `isNativeElement` combines authoritative html/svg/
// mathml tag lists with scope-shadowing detection.
if (!isNativeElement(node, sourceCode)) {
return;
}
if (node.tag?.toLowerCase() !== 'a') {
return;
}

// Only anchors acting as links (with href) are in scope. An <a> without
// href is covered by `template-link-href-attributes` / not a link.
const attrs = node.attributes || [];
const hasHref = attrs.some((a) => a.name === 'href');
if (!hasHref) {
return;
}
Comment thread
johanrd marked this conversation as resolved.

// Skip anchors the author has explicitly hidden — either via the HTML
// `hidden` boolean attribute (element is not rendered at all) or
// `aria-hidden="true"` (element removed from the accessibility tree).
// In both cases, "accessible name of an anchor" is moot.
const hasHidden = attrs.some((a) => a.name === 'hidden');
if (hasHidden) {
return;
}
Comment on lines +215 to +222
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This skips reporting whenever a hidden attribute is present, but in templates hidden={{false}} should not skip the rule (the anchor is not hidden at runtime). This creates false negatives where empty anchors can slip through linting. Recommend changing the logic to only skip when hidden is statically true; if hidden is dynamic/unknown, consider the project’s “fewer false positives” stance and either (a) treat dynamic as hidden (skip) consistently, or (b) treat dynamic as unknown and skip reporting (similar to how dynamic content is handled), but avoid treating hidden={{false}} as hidden.

Copilot uses AI. Check for mistakes.
const ariaHiddenAttr = attrs.find((a) => a.name === 'aria-hidden');
if (isAriaHiddenTrue(ariaHiddenAttr)) {
return;
}

if (hasAccessibleNameAttribute(node)) {
return;
Comment thread
johanrd marked this conversation as resolved.
}

const result = evaluateChildren(node.children || [], sourceCode);
if (result.accessible || result.dynamic) {
return;
}

context.report({ node, messageId: 'anchorHasContent' });
},
};
},
};
16 changes: 7 additions & 9 deletions lib/utils/is-native-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,17 @@ const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
* MathML spec registries, reached via the `html-tags` / `svg-tags` /
* `mathml-tag-names` packages). It is NOT the same as:
*
* - "native accessibility" / "widget-ness" — see `interactive-roles.js`
* (aria-query widget taxonomy; an ARIA-tree-semantics question)
* - "native interactive content" / "focus behavior" — see
* `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model
* question about which tags can be nested inside what)
* - "native accessibility" / "widget-ness" — an ARIA-tree-semantics
* question (for example, whether something maps to a widget role)
* - "native interactive content" / "focus behavior" — an HTML content-model
* question about which elements are considered interactive in the spec
* - "natively focusable" / sequential-focus — see HTML §6.6.3
*
* This util answers only: "is this tag a first-class built-in element of one
* of the three markup-language standards, rather than a component invocation
* or a shadowed local binding?" Callers compose it with the other utils
* above when they need a more specific question (see e.g. `template-no-
* noninteractive-tabindex`, which consults both this and
* `html-interactive-content`).
* or a shadowed local binding?" Callers should combine it with whatever
* accessibility, interactivity, or focusability checks they need for more
* specific questions.
*
* Returns false for:
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
Expand Down
Loading
Loading