Skip to content
Draft
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.

## 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)
260 changes: 260 additions & 0 deletions lib/rules/template-anchor-has-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
'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;
}
return resolved.trim().toLowerCase() === 'true';
}

// True if the HTML boolean `hidden` attribute renders as present at runtime.
// Per docs/glimmer-attribute-behavior.md, only bare-mustache boolean-false
// (`hidden={{false}}`) is omitted at runtime. Concat (`hidden="{{false}}"`)
// and string-literal (`hidden={{"false"}}`) forms keep the attribute present
// (and HTML treats any presence — including value "false" — as ON).
function rendersHidden(attr) {
if (!attr) {
return false;
}
const v = attr.value;
if (
v?.type === 'GlimmerMustacheStatement' &&
v.path?.type === 'GlimmerBooleanLiteral' &&
v.path.value === false
) {
return false;
}
return true;
}

// 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;
}
}
}
return false;
}

// 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 };
}

// 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 (rendersHidden(attrs.find((a) => a.name === 'hidden'))) {
return { dynamic: false, accessible: false };
}

// 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 };
}

// 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.',
},
},

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;
}

// 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.
if (rendersHidden(attrs.find((a) => a.name === 'hidden'))) {
return;
}
const ariaHiddenAttr = attrs.find((a) => a.name === 'aria-hidden');
if (isAriaHiddenTrue(ariaHiddenAttr)) {
return;
}

if (hasAccessibleNameAttribute(node)) {
return;
}

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