-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add template-anchor-has-content #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| 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; | ||
| } | ||
|
johanrd marked this conversation as resolved.
|
||
| return resolved.trim().toLowerCase() === 'true'; | ||
|
Comment on lines
+22
to
+27
|
||
| } | ||
|
|
||
| // 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 ` ` to space before the whitespace check — matches the | ||
| // sibling rule `template-no-invalid-link-text`. `aria-label=" "` | ||
| // 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(' ', ' '); | ||
| if (chars.trim().length > 0) { | ||
| return true; | ||
| } | ||
| } | ||
|
johanrd marked this conversation as resolved.
|
||
| } | ||
| return false; | ||
|
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(' ', ' ').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 }; | ||
| } | ||
|
|
||
|
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
|
||
|
|
||
| // 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 ` ` normalization as hasAccessibleNameAttribute above — | ||
| // `<img alt=" ">` contributes no meaningful name. | ||
| const chars = altAttr.value.chars.replaceAll(' ', ' '); | ||
| return { dynamic: false, accessible: chars.trim().length > 0 }; | ||
| } | ||
| return { dynamic: false, accessible: false }; | ||
|
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.', | ||
| }, | ||
| }, | ||
|
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; | ||
| } | ||
|
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
|
||
| const ariaHiddenAttr = attrs.find((a) => a.name === 'aria-hidden'); | ||
| if (isAriaHiddenTrue(ariaHiddenAttr)) { | ||
| return; | ||
| } | ||
|
|
||
| if (hasAccessibleNameAttribute(node)) { | ||
| return; | ||
|
johanrd marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const result = evaluateChildren(node.children || [], sourceCode); | ||
| if (result.accessible || result.dynamic) { | ||
| return; | ||
| } | ||
|
|
||
| context.report({ node, messageId: 'anchorHasContent' }); | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.