|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +const { isNativeElement } = require('../utils/is-native-element'); |
| 4 | +const { getStaticAttrValue } = require('../utils/static-attr-value'); |
| 5 | + |
| 6 | +function isDynamicValue(value) { |
| 7 | + return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement'; |
| 8 | +} |
| 9 | + |
| 10 | +// Returns true if the `aria-hidden` attribute is explicitly set to "true" |
| 11 | +// (case-insensitive) or mustache-literal `{{true}}` / `{{"true"}}` / the |
| 12 | +// quoted-mustache concat equivalents. Per WAI-ARIA 1.2 §6.6 + aria-hidden |
| 13 | +// value table, valueless / empty-string `aria-hidden` resolves to the |
| 14 | +// default `undefined` — NOT `true` — so those forms do NOT hide the |
| 15 | +// element per spec. This aligns with the spec-first decisions in #2717 / |
| 16 | +// #19 / #33, and diverges from jsx-a11y's JSX-coercion convention. All |
| 17 | +// shape-unwrapping is delegated to the shared `getStaticAttrValue` helper. |
| 18 | +function isAriaHiddenTrue(attr) { |
| 19 | + if (!attr) { |
| 20 | + return false; |
| 21 | + } |
| 22 | + const resolved = getStaticAttrValue(attr.value); |
| 23 | + if (resolved === undefined) { |
| 24 | + // Dynamic — can't prove truthy. |
| 25 | + return false; |
| 26 | + } |
| 27 | + return resolved.trim().toLowerCase() === 'true'; |
| 28 | +} |
| 29 | + |
| 30 | +// True if the anchor itself declares an accessible name via a statically |
| 31 | +// non-empty `aria-label`, `aria-labelledby`, or `title`, OR via a dynamic |
| 32 | +// value (we can't know at lint time whether a mustache resolves to an empty |
| 33 | +// string, so we give the author the benefit of the doubt — matching the |
| 34 | +// "skip dynamic" posture used by `template-no-invalid-link-text`). |
| 35 | +function hasAccessibleNameAttribute(node) { |
| 36 | + const attrs = node.attributes || []; |
| 37 | + for (const name of ['aria-label', 'aria-labelledby', 'title']) { |
| 38 | + const attr = attrs.find((a) => a.name === name); |
| 39 | + if (!attr) { |
| 40 | + continue; |
| 41 | + } |
| 42 | + if (attr.value?.type === 'GlimmerMustacheStatement') { |
| 43 | + const resolved = getStaticAttrValue(attr.value); |
| 44 | + if (resolved === undefined) { |
| 45 | + // Truly dynamic (e.g. `aria-label={{@label}}`) — can't know at lint |
| 46 | + // time; give the author the benefit of the doubt. |
| 47 | + return true; |
| 48 | + } |
| 49 | + // Static string literal in mustache, e.g. `aria-label={{""}}`. |
| 50 | + // Treat exactly like a plain text value: non-empty means a name exists. |
| 51 | + if (resolved.trim().length > 0) { |
| 52 | + return true; |
| 53 | + } |
| 54 | + continue; |
| 55 | + } |
| 56 | + if (isDynamicValue(attr.value)) { |
| 57 | + // GlimmerConcatStatement — treat as dynamic. |
| 58 | + return true; |
| 59 | + } |
| 60 | + if (attr.value?.type === 'GlimmerTextNode') { |
| 61 | + // Normalize ` ` to space before the whitespace check — matches the |
| 62 | + // sibling rule `template-no-invalid-link-text`. `aria-label=" "` |
| 63 | + // is functionally empty for assistive tech (no visual content, no |
| 64 | + // announced text) and shouldn't count as an accessible name. |
| 65 | + const chars = attr.value.chars.replaceAll(' ', ' '); |
| 66 | + if (chars.trim().length > 0) { |
| 67 | + return true; |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + return false; |
| 72 | +} |
| 73 | + |
| 74 | +// Recursively inspect a single child node and report how it would contribute |
| 75 | +// to the anchor's accessible name. |
| 76 | +// { dynamic: true } — opaque at lint time; treat anchor as labeled. |
| 77 | +// { accessible: true } — statically contributes a non-empty name. |
| 78 | +// { accessible: false } — contributes nothing (empty text, aria-hidden |
| 79 | +// subtree, <img> without non-empty alt, …). |
| 80 | +function evaluateChild(child, sourceCode) { |
| 81 | + if (child.type === 'GlimmerTextNode') { |
| 82 | + const text = child.chars.replaceAll(' ', ' ').trim(); |
| 83 | + return { dynamic: false, accessible: text.length > 0 }; |
| 84 | + } |
| 85 | + |
| 86 | + if ( |
| 87 | + child.type === 'GlimmerMustacheStatement' || |
| 88 | + child.type === 'GlimmerSubExpression' || |
| 89 | + child.type === 'GlimmerBlockStatement' |
| 90 | + ) { |
| 91 | + // Dynamic content — can't statically tell whether it renders to something. |
| 92 | + // Mirror `template-no-invalid-link-text`'s stance and skip. |
| 93 | + return { dynamic: true, accessible: false }; |
| 94 | + } |
| 95 | + |
| 96 | + if (child.type === 'GlimmerElementNode') { |
| 97 | + const attrs = child.attributes || []; |
| 98 | + const ariaHidden = attrs.find((a) => a.name === 'aria-hidden'); |
| 99 | + if (isAriaHiddenTrue(ariaHidden)) { |
| 100 | + // aria-hidden subtrees contribute nothing, regardless of content. |
| 101 | + return { dynamic: false, accessible: false }; |
| 102 | + } |
| 103 | + |
| 104 | + // HTML boolean `hidden` (§5.4) removes the element from rendering AND |
| 105 | + // from the accessibility tree — equivalent to aria-hidden="true" for |
| 106 | + // accessible-name purposes. A <span hidden>Backup</span> inside an |
| 107 | + // anchor contributes no name at runtime. |
| 108 | + if (attrs.some((a) => a.name === 'hidden')) { |
| 109 | + return { dynamic: false, accessible: false }; |
| 110 | + } |
| 111 | + |
| 112 | + // Non-native children (components, custom elements, scope-shadowed tags) |
| 113 | + // are opaque — we can't see inside them. |
| 114 | + if (!isNativeElement(child, sourceCode)) { |
| 115 | + return { dynamic: true, accessible: false }; |
| 116 | + } |
| 117 | + |
| 118 | + // An <img> child contributes its alt text to the anchor's accessible name. |
| 119 | + if (child.tag?.toLowerCase() === 'img') { |
| 120 | + const altAttr = attrs.find((a) => a.name === 'alt'); |
| 121 | + if (!altAttr) { |
| 122 | + // Missing alt is a separate a11y concern; treat as no contribution. |
| 123 | + return { dynamic: false, accessible: false }; |
| 124 | + } |
| 125 | + if (altAttr.value?.type === 'GlimmerMustacheStatement') { |
| 126 | + const resolved = getStaticAttrValue(altAttr.value); |
| 127 | + if (resolved === undefined) { |
| 128 | + // Truly dynamic (e.g. `alt={{@alt}}`) — trust the author. |
| 129 | + return { dynamic: true, accessible: false }; |
| 130 | + } |
| 131 | + // Static string literal in mustache, e.g. `alt={{""}}` or |
| 132 | + // `alt={{"Search"}}` — treat exactly like a plain text value. |
| 133 | + return { dynamic: false, accessible: resolved.trim().length > 0 }; |
| 134 | + } |
| 135 | + if (isDynamicValue(altAttr.value)) { |
| 136 | + // GlimmerConcatStatement — treat as dynamic. |
| 137 | + return { dynamic: true, accessible: false }; |
| 138 | + } |
| 139 | + if (altAttr.value?.type === 'GlimmerTextNode') { |
| 140 | + // Same ` ` normalization as hasAccessibleNameAttribute above — |
| 141 | + // `<img alt=" ">` contributes no meaningful name. |
| 142 | + const chars = altAttr.value.chars.replaceAll(' ', ' '); |
| 143 | + return { dynamic: false, accessible: chars.trim().length > 0 }; |
| 144 | + } |
| 145 | + return { dynamic: false, accessible: false }; |
| 146 | + } |
| 147 | + |
| 148 | + // For any other HTML element child, recurse into its children AND its own |
| 149 | + // aria-label/aria-labelledby/title (author may label an inner <span>). |
| 150 | + if (hasAccessibleNameAttribute(child)) { |
| 151 | + return { dynamic: false, accessible: true }; |
| 152 | + } |
| 153 | + |
| 154 | + return evaluateChildren(child.children || [], sourceCode); |
| 155 | + } |
| 156 | + |
| 157 | + return { dynamic: false, accessible: false }; |
| 158 | +} |
| 159 | + |
| 160 | +function evaluateChildren(children, sourceCode) { |
| 161 | + let dynamic = false; |
| 162 | + for (const child of children) { |
| 163 | + const result = evaluateChild(child, sourceCode); |
| 164 | + if (result.accessible) { |
| 165 | + return { dynamic: false, accessible: true }; |
| 166 | + } |
| 167 | + if (result.dynamic) { |
| 168 | + dynamic = true; |
| 169 | + } |
| 170 | + } |
| 171 | + return { dynamic, accessible: false }; |
| 172 | +} |
| 173 | + |
| 174 | +/** @type {import('eslint').Rule.RuleModule} */ |
| 175 | +module.exports = { |
| 176 | + meta: { |
| 177 | + type: 'problem', |
| 178 | + docs: { |
| 179 | + description: 'require anchor elements to contain accessible content', |
| 180 | + category: 'Accessibility', |
| 181 | + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-anchor-has-content.md', |
| 182 | + templateMode: 'both', |
| 183 | + }, |
| 184 | + fixable: null, |
| 185 | + schema: [], |
| 186 | + messages: { |
| 187 | + anchorHasContent: |
| 188 | + 'Anchors must have content and the content must be accessible by a screen reader.', |
| 189 | + }, |
| 190 | + }, |
| 191 | + |
| 192 | + create(context) { |
| 193 | + const sourceCode = context.sourceCode || context.getSourceCode(); |
| 194 | + return { |
| 195 | + GlimmerElementNode(node) { |
| 196 | + // Only the native <a> element — in strict GJS, a lowercase tag can be |
| 197 | + // shadowed by an in-scope local binding, and components shouldn't be |
| 198 | + // validated here. `isNativeElement` combines authoritative html/svg/ |
| 199 | + // mathml tag lists with scope-shadowing detection. |
| 200 | + if (!isNativeElement(node, sourceCode)) { |
| 201 | + return; |
| 202 | + } |
| 203 | + if (node.tag?.toLowerCase() !== 'a') { |
| 204 | + return; |
| 205 | + } |
| 206 | + |
| 207 | + // Only anchors acting as links (with href) are in scope. An <a> without |
| 208 | + // href is covered by `template-link-href-attributes` / not a link. |
| 209 | + const attrs = node.attributes || []; |
| 210 | + const hasHref = attrs.some((a) => a.name === 'href'); |
| 211 | + if (!hasHref) { |
| 212 | + return; |
| 213 | + } |
| 214 | + |
| 215 | + // Skip anchors the author has explicitly hidden — either via the HTML |
| 216 | + // `hidden` boolean attribute (element is not rendered at all) or |
| 217 | + // `aria-hidden="true"` (element removed from the accessibility tree). |
| 218 | + // In both cases, "accessible name of an anchor" is moot. |
| 219 | + const hasHidden = attrs.some((a) => a.name === 'hidden'); |
| 220 | + if (hasHidden) { |
| 221 | + return; |
| 222 | + } |
| 223 | + const ariaHiddenAttr = attrs.find((a) => a.name === 'aria-hidden'); |
| 224 | + if (isAriaHiddenTrue(ariaHiddenAttr)) { |
| 225 | + return; |
| 226 | + } |
| 227 | + |
| 228 | + if (hasAccessibleNameAttribute(node)) { |
| 229 | + return; |
| 230 | + } |
| 231 | + |
| 232 | + const result = evaluateChildren(node.children || [], sourceCode); |
| 233 | + if (result.accessible || result.dynamic) { |
| 234 | + return; |
| 235 | + } |
| 236 | + |
| 237 | + context.report({ node, messageId: 'anchorHasContent' }); |
| 238 | + }, |
| 239 | + }; |
| 240 | + }, |
| 241 | +}; |
0 commit comments