|
| 1 | +// Matches a tag string that is a component invocation rather than a plain |
| 2 | +// HTML element: PascalCase (`Foo`), argument-invocation (`@foo`), path on |
| 3 | +// `this.` (`this.foo`), dotted path (`foo.bar`), or named-block-style |
| 4 | +// `foo::bar`. Keep this mirrored with the inline pattern in |
| 5 | +// lib/rules/template-no-invalid-interactive.js until a shared utility lands. |
| 6 | +function isComponentInvocation(tag) { |
| 7 | + if (!tag) { |
| 8 | + return false; |
| 9 | + } |
| 10 | + return ( |
| 11 | + /^[A-Z]/.test(tag) || |
| 12 | + tag.startsWith('@') || |
| 13 | + tag.startsWith('this.') || |
| 14 | + tag.includes('.') || |
| 15 | + tag.includes('::') |
| 16 | + ); |
| 17 | +} |
| 18 | + |
| 19 | +function isDynamicValue(value) { |
| 20 | + return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement'; |
| 21 | +} |
| 22 | + |
| 23 | +// Returns true if the `aria-hidden` attribute is effectively truthy. Mirrors |
| 24 | +// the jsx-a11y/vue-a11y convention: valueless (`aria-hidden`), string `"true"`, |
| 25 | +// or `{{true}}` all hide the element from the a11y tree. Dynamic values are |
| 26 | +// treated as "hidden" only when the developer explicitly passes boolean true; |
| 27 | +// anything we cannot statically resolve falls through as not-hidden so we |
| 28 | +// don't silently swallow meaningful content. |
| 29 | +function isAriaHiddenTrue(attr) { |
| 30 | + if (!attr) { |
| 31 | + return false; |
| 32 | + } |
| 33 | + // Valueless attribute (e.g. `<span aria-hidden />`) parses with no value. |
| 34 | + if (attr.value === undefined || attr.value === null) { |
| 35 | + return true; |
| 36 | + } |
| 37 | + if (attr.value.type === 'GlimmerTextNode') { |
| 38 | + const chars = attr.value.chars.trim().toLowerCase(); |
| 39 | + // HTML parses bare `aria-hidden` as `aria-hidden=""`; treat empty as true |
| 40 | + // to mirror the valueless shape above. |
| 41 | + return chars === '' || chars === 'true'; |
| 42 | + } |
| 43 | + if (attr.value.type === 'GlimmerMustacheStatement') { |
| 44 | + const path = attr.value.path; |
| 45 | + if (path?.type === 'GlimmerBooleanLiteral') { |
| 46 | + return path.value === true; |
| 47 | + } |
| 48 | + if (path?.type === 'GlimmerStringLiteral') { |
| 49 | + return path.value.trim().toLowerCase() === 'true'; |
| 50 | + } |
| 51 | + } |
| 52 | + return false; |
| 53 | +} |
| 54 | + |
| 55 | +// True if the anchor itself declares an accessible name via a statically |
| 56 | +// non-empty `aria-label`, `aria-labelledby`, or `title`, OR via a dynamic |
| 57 | +// value (we can't know at lint time whether a mustache resolves to an empty |
| 58 | +// string, so we give the author the benefit of the doubt — matching the |
| 59 | +// "skip dynamic" posture used by `template-no-invalid-link-text`). |
| 60 | +function hasAccessibleNameAttribute(node) { |
| 61 | + const attrs = node.attributes || []; |
| 62 | + for (const name of ['aria-label', 'aria-labelledby', 'title']) { |
| 63 | + const attr = attrs.find((a) => a.name === name); |
| 64 | + if (!attr) { |
| 65 | + continue; |
| 66 | + } |
| 67 | + if (isDynamicValue(attr.value)) { |
| 68 | + return true; |
| 69 | + } |
| 70 | + if (attr.value?.type === 'GlimmerTextNode' && attr.value.chars.trim().length > 0) { |
| 71 | + return true; |
| 72 | + } |
| 73 | + } |
| 74 | + return false; |
| 75 | +} |
| 76 | + |
| 77 | +// Recursively inspect a single child node and report how it would contribute |
| 78 | +// to the anchor's accessible name. |
| 79 | +// { dynamic: true } — opaque at lint time; treat anchor as labeled. |
| 80 | +// { accessible: true } — statically contributes a non-empty name. |
| 81 | +// { accessible: false } — contributes nothing (empty text, aria-hidden |
| 82 | +// subtree, <img> without non-empty alt, …). |
| 83 | +function evaluateChild(child) { |
| 84 | + if (child.type === 'GlimmerTextNode') { |
| 85 | + const text = child.chars.replaceAll(' ', ' ').trim(); |
| 86 | + return { dynamic: false, accessible: text.length > 0 }; |
| 87 | + } |
| 88 | + |
| 89 | + if ( |
| 90 | + child.type === 'GlimmerMustacheStatement' || |
| 91 | + child.type === 'GlimmerSubExpression' || |
| 92 | + child.type === 'GlimmerBlockStatement' |
| 93 | + ) { |
| 94 | + // Dynamic content — can't statically tell whether it renders to something. |
| 95 | + // Mirror `template-no-invalid-link-text`'s stance and skip. |
| 96 | + return { dynamic: true, accessible: false }; |
| 97 | + } |
| 98 | + |
| 99 | + if (child.type === 'GlimmerElementNode') { |
| 100 | + const attrs = child.attributes || []; |
| 101 | + const ariaHidden = attrs.find((a) => a.name === 'aria-hidden'); |
| 102 | + if (isAriaHiddenTrue(ariaHidden)) { |
| 103 | + // aria-hidden subtrees contribute nothing, regardless of content. |
| 104 | + return { dynamic: false, accessible: false }; |
| 105 | + } |
| 106 | + |
| 107 | + // Component invocations are opaque — we can't see inside them. |
| 108 | + if (isComponentInvocation(child.tag)) { |
| 109 | + return { dynamic: true, accessible: false }; |
| 110 | + } |
| 111 | + |
| 112 | + // An <img> child contributes its alt text to the anchor's accessible name. |
| 113 | + if (child.tag?.toLowerCase() === 'img') { |
| 114 | + const altAttr = attrs.find((a) => a.name === 'alt'); |
| 115 | + if (!altAttr) { |
| 116 | + // Missing alt is a separate a11y concern; treat as no contribution. |
| 117 | + return { dynamic: false, accessible: false }; |
| 118 | + } |
| 119 | + if (isDynamicValue(altAttr.value)) { |
| 120 | + return { dynamic: true, accessible: false }; |
| 121 | + } |
| 122 | + if (altAttr.value?.type === 'GlimmerTextNode') { |
| 123 | + return { dynamic: false, accessible: altAttr.value.chars.trim().length > 0 }; |
| 124 | + } |
| 125 | + return { dynamic: false, accessible: false }; |
| 126 | + } |
| 127 | + |
| 128 | + // For any other HTML element child, recurse into its children AND its own |
| 129 | + // aria-label/aria-labelledby/title (author may label an inner <span>). |
| 130 | + if (hasAccessibleNameAttribute(child)) { |
| 131 | + return { dynamic: false, accessible: true }; |
| 132 | + } |
| 133 | + |
| 134 | + return evaluateChildren(child.children || []); |
| 135 | + } |
| 136 | + |
| 137 | + return { dynamic: false, accessible: false }; |
| 138 | +} |
| 139 | + |
| 140 | +function evaluateChildren(children) { |
| 141 | + let dynamic = false; |
| 142 | + for (const child of children) { |
| 143 | + const result = evaluateChild(child); |
| 144 | + if (result.accessible) { |
| 145 | + return { dynamic: false, accessible: true }; |
| 146 | + } |
| 147 | + if (result.dynamic) { |
| 148 | + dynamic = true; |
| 149 | + } |
| 150 | + } |
| 151 | + return { dynamic, accessible: false }; |
| 152 | +} |
| 153 | + |
| 154 | +/** @type {import('eslint').Rule.RuleModule} */ |
| 155 | +module.exports = { |
| 156 | + meta: { |
| 157 | + type: 'problem', |
| 158 | + docs: { |
| 159 | + description: 'require anchor elements to contain accessible content', |
| 160 | + category: 'Accessibility', |
| 161 | + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-anchor-has-content.md', |
| 162 | + templateMode: 'both', |
| 163 | + }, |
| 164 | + schema: [], |
| 165 | + messages: { |
| 166 | + anchorHasContent: |
| 167 | + 'Anchors must have content and the content must be accessible by a screen reader.', |
| 168 | + }, |
| 169 | + }, |
| 170 | + |
| 171 | + create(context) { |
| 172 | + return { |
| 173 | + GlimmerElementNode(node) { |
| 174 | + if (node.tag !== 'a') { |
| 175 | + return; |
| 176 | + } |
| 177 | + |
| 178 | + // Only anchors acting as links (with href) are in scope. An <a> without |
| 179 | + // href is covered by `template-link-href-attributes` / not a link. |
| 180 | + const hasHref = (node.attributes || []).some((a) => a.name === 'href'); |
| 181 | + if (!hasHref) { |
| 182 | + return; |
| 183 | + } |
| 184 | + |
| 185 | + if (hasAccessibleNameAttribute(node)) { |
| 186 | + return; |
| 187 | + } |
| 188 | + |
| 189 | + const result = evaluateChildren(node.children || []); |
| 190 | + if (result.accessible || result.dynamic) { |
| 191 | + return; |
| 192 | + } |
| 193 | + |
| 194 | + context.report({ node, messageId: 'anchorHasContent' }); |
| 195 | + }, |
| 196 | + }; |
| 197 | + }, |
| 198 | +}; |
0 commit comments