Conversation
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).
🏎️ Benchmark Comparison
Full mitata output |
…I-ARIA spec Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string aria-hidden resolves to the default `undefined` — NOT `true`. So <span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the span; its content still contributes to the anchor's accessible name. The prior behavior inherited jsx-a11y's JSX-coercion convention and vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin conventions that diverge from normative ARIA. Matches the spec-first resolution of ember-cli#2717, #19, and #33. Moved valueless / empty aria-hidden cases from invalid → valid. Kept the explicit aria-hidden="true" and {{true}} cases as invalid.
…+ scope) Remove the inline PascalCase-regex isComponentInvocation heuristic in favor of the lists + scope pattern from ember-cli#2689 / ember-cli#2724. The new lib/utils/is-native-element.js mirrors ember-cli#2724's util byte-for-byte so the two PRs can land in either order without conflict. evaluateChild / evaluateChildren now thread sourceCode through the recursion so the scope check has access to the enclosing template's bindings. Heuristic approaches were explicitly rejected in ember-cli#2689 because lowercase tags CAN be components when shadowed by scope bindings. Treating custom elements as opaque (the same as components) is a behavior improvement — matches ember-cli#2724's convention.
There was a problem hiding this comment.
Pull request overview
Adds a new accessibility-focused template rule to the plugin to ensure <a href> elements expose a non-empty accessible name, aligning behavior with similar ecosystem rules while avoiding false positives for dynamic content.
Changes:
- Introduces
template-anchor-has-contentrule with recursive accessible-name source detection (text,aria-*,title,<img alt>,aria-hiddenhandling). - Adds
isNativeElementutility (HTML/SVG/MathML allowlist + scope-shadowing check) and unit tests. - Adds rule documentation and lists the new rule in the README.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
lib/rules/template-anchor-has-content.js |
Implements the new rule’s logic for detecting unlabeled <a href> anchors. |
lib/utils/is-native-element.js |
Adds shared helper for native-element vs component discrimination (including scope shadowing). |
tests/lib/rules/template-anchor-has-content.js |
Adds rule tests for both .gjs and .hbs parsing modes. |
tests/lib/utils/is-native-element-test.js |
Adds unit tests for list-based native-element detection and tag set sanity checks. |
docs/rules/template-anchor-has-content.md |
Documents rule behavior and examples. |
README.md |
Adds the rule to the accessibility rules table. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
'Native' is overloaded in the web platform context. The util name
remains isNativeElement (common convention in React/Vue/Angular
ecosystems for 'platform-provided, not a component'), but the JSDoc
now explicitly names three alternative meanings of 'native' that
this util does NOT answer:
- 'native accessibility' / 'widget-ness' — interactive-roles.js
(aria-query widget taxonomy)
- 'native interactive content' — html-interactive-content.js
(HTML §3.2.5.2.7 content-model question)
- 'natively focusable' — HTML §6.6.3 sequential focus navigation
This util answers only: is this tag a first-class built-in element
of HTML/SVG/MathML, rather than a component invocation or a
scope-shadowed local binding? Callers compose it with the more
specific utils when they need a narrower question.
…mples (Copilot review)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
a071bfc to
6030630
Compare
… checks (Copilot review)
…no accessible name (Copilot review)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…r scope-shadowing (Copilot review)
…-function-scoping)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ment, is-native-element JSDoc sync (Copilot review) - Add meta.fixable: null — the rule is not autofixable; be explicit so eslint-plugin/consistent-output / downstream tooling doesn't misclassify it. - Clarify test comment: 'detects the shadowing via scope bindings in the scope chain' (the implementation walks scope.variables up scope.upper, it does not use scope.references). - Byte-identical sync of lib/utils/is-native-element.js (+ test) to PR #50 canonical; drops the stale template-no-noninteractive-tabindex JSDoc reference (non-existent rule on master).
… helper (Copilot review)
Extract a new `getStaticAttrValue` util that resolves literal-valued
mustaches (`{{"foo"}}`, `{{true}}`, `{{-1}}`) and single-part concat
statements (`"{{true}}"`) to their static string value. `isAriaHiddenTrue`
now delegates to the helper, so quoted-mustache forms of aria-hidden
(e.g. `<a aria-hidden="{{true}}">link</a>`) are recognised the same as
their text-node counterparts when walking descendants for accessible
content.
Byte-identical carrier of lib/utils/static-attr-value.js across all PRs
that land it.
… via shared helper (Copilot review)
Extract a new `getStaticAttrValue` util that resolves literal-valued
mustaches (`{{"foo"}}`, `{{true}}`, `{{-1}}`) and single-part concat
statements (`"{{true}}"`) to their static string value. `isAriaHiddenTruthy`
now delegates to the helper and compares the resolved string to `'true'`
(case-insensitive, whitespace-trimmed).
Behavior change: valueless `<h1 aria-hidden>`, `aria-hidden=""`, and the
mustache-empty-string equivalents (`aria-hidden={{""}}`, `aria-hidden="{{""}}"`,
`aria-hidden={{" "}}`) are no longer treated as hidden. Per WAI-ARIA 1.2
§6.6 value table, those shapes resolve to the default `undefined` — NOT
`true` — so the empty-content check still applies. Drops the previous
"fewer false positives" deviation rationale in favour of spec-literal
consistency with sibling rules (#19, #35, #41) that share the same
aria-hidden resolution.
Byte-identical carrier of lib/utils/static-attr-value.js across all PRs
that land it.
Note
This is part of a series where Claude has audited
eslint-plugin-emberagainst jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate,ember-template-lint, and the HTML and WCAG specs.Summary
<a href>rendered to the DOM is exposed as a link to assistive tech. Per ACCNAME 1.2 §4.3.2 Computation steps, the accessible name is computed in order fromaria-labelledby(step 2B), thenaria-label(step 2D), then host-language label sources / descendant text content (steps 2E–2F), then a tooltip attribute such astitle(step 2I). For an<a>element with none of these, the result is the empty string. An anchor with none of these has no accessible name; a screen reader announces "link" with nothing else. Authoring guidance: WCAG 2.1 SC 2.4.4 Link Purpose.template-anchor-has-content. Flags<a href>with no text, no accessible-name attribute (aria-label/aria-labelledby/title), and no child that contributes an accessible name. Dynamic cases (mustache-only content) stay accepted — we can't know at lint time whether they resolve to a non-empty name.Four ecosystem positions on valueless aria-hidden
The question "what does
<span aria-hidden>(bare),aria-hidden=""(empty), oraria-hidden={{false}}mean?" has no single authoritative answer:jsx-ast-utilscoercing valueless JSX → booleantrue. Quirk: stringaria-hidden="true"is NOT recognized. Not a deliberate ARIA interpretation."false"→ hiddenisHiddenFromScreenReader.ts: non-spec shortcut.undefined→ not hiddenDesign choice for this rule
We lean toward fewer false positives. For this rule, that means treating a child with valueless / empty aria-hidden as NOT hidden — if someone writes
<a href="/x"><span aria-hidden>X</span></a>, the child's content likely still contributes a name, so we don't flag the anchor as having no accessible content. Only explicitaria-hidden="true"/{{true}}hides the child subtree from the name computation.Prior art
Verified each peer in source:
anchor-has-content<a>without text content or a recognized accessible-name attribute.anchor-has-content<a>lacking content andaria-label; configurableaccessibleChildren/accessibleDirectiveslet callers widen what counts as a name source.anchor-has-contentequivalent. Itsanchor-is-validcovers href validity (noHref/invalidHref/preferButton aspects), not accessible-name content.Flags
Allows