diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js
index 7e7193c936..a08b160bb9 100644
--- a/lib/rules/template-require-valid-alt-text.js
+++ b/lib/rules/template-require-valid-alt-text.js
@@ -12,6 +12,34 @@ function hasAnyAttr(node, names) {
return names.some((name) => hasAttr(node, name));
}
+/**
+ * Returns true if the named attribute is present with a non-empty, non-whitespace
+ * static value, OR present with a dynamic (mustache/concat) value. Dynamic values
+ * are assumed to resolve to a meaningful name at runtime (we can't verify at lint
+ * time). Static empty-string / whitespace-only values return false — applied to
+ * alt / aria-label / aria-labelledby / title. The empty-name treatment aligns with
+ * ACCNAME 1.2 §4.3.2's aria-label step (2D), which normalizes empty/whitespace
+ * to "no name"; we apply the same normalization to the other fallbacks.
+ *
+ * NOTE: This does not validate that aria-labelledby IDREFs reference existing IDs.
+ * Rule consumers should layer that check separately if needed.
+ */
+function hasNonEmptyTextAttr(node, name) {
+ const attr = findAttr(node, name);
+ if (!attr?.value) {
+ return false;
+ }
+ if (attr.value.type === 'GlimmerTextNode') {
+ return attr.value.chars.trim() !== '';
+ }
+ // Mustache / concat — dynamic; assume truthy.
+ return true;
+}
+
+function hasAnyNonEmptyTextAttr(node, names) {
+ return names.some((name) => hasNonEmptyTextAttr(node, name));
+}
+
function getTextValue(attr) {
if (!attr?.value) {
return undefined;
@@ -166,7 +194,9 @@ module.exports = {
return;
}
- if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
+ // Empty-string aria-label/aria-labelledby/alt provides no accessible
+ // name — require a non-empty fallback value.
+ if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
context.report({ node, messageId: 'inputImage' });
}
@@ -177,7 +207,7 @@ module.exports = {
const roleValue = getTextValue(roleAttr);
if (
- hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
+ hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
hasChildren(node) ||
(roleValue && ['presentation', 'none'].includes(roleValue))
) {
@@ -189,7 +219,7 @@ module.exports = {
break;
}
case 'area': {
- if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
+ if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
context.report({ node, messageId: 'areaMissing' });
}
diff --git a/tests/audit/alt-text/peer-parity.js b/tests/audit/alt-text/peer-parity.js
new file mode 100644
index 0000000000..d0ca3661a6
--- /dev/null
+++ b/tests/audit/alt-text/peer-parity.js
@@ -0,0 +1,198 @@
+// Audit fixture — translates peer-plugin test cases into assertions against
+// our rule (`ember/template-require-valid-alt-text`). Runs as part of the
+// default Vitest suite (via the `tests/**/*.js` include glob) and serves
+// double-duty: (1) auditable record of peer-parity divergences,
+// (2) regression coverage pinning CURRENT behavior. Each case encodes what
+// OUR rule does today; divergences from upstream plugins are annotated as
+// `DIVERGENCE —`. Peer-only constructs that can't be translated to Ember
+// templates (JSX spread props, Vue v-bind, Angular `$event`, undefined-handler
+// expression analysis) are marked `AUDIT-SKIP`.
+// See docs/audit-a11y-behavior.md for the summary of divergences.
+//
+// Source files:
+// - context/eslint-plugin-jsx-a11y-main/__tests__/src/rules/alt-text-test.js
+// - context/eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/alt-text.test.ts
+// - context/eslint-plugin-lit-a11y/tests/lib/rules/alt-text.js
+
+'use strict';
+
+const rule = require('../../../lib/rules/template-require-valid-alt-text');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('audit:alt-text (gts)', rule, {
+ valid: [
+ // === Upstream parity (valid in jsx-a11y + ours) ===
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ // DIVERGENCE — moved to invalid below:
+ // '
',
+ '
',
+ // object with label/children
+ '',
+ '',
+ '',
+ '',
+ // area with label
+ '',
+ '',
+ '',
+ // input[type=image]
+ '',
+ '',
+ '',
+
+ // === DIVERGENCE — aria-label/aria-labelledby on
without alt ===
+ // jsx-a11y: VALID — `
` is accepted.
+ // vue-a11y: VALID — same.
+ // Our rule: INVALID — requires `alt` attribute on
, full stop.
+ // Spec reading: the HTML spec mandates alt on
. WAI-ARIA accepts
+ // aria-label/aria-labelledby as alternative accessible-name sources. The
+ // two specs disagree; we side with HTML-strict.
+ // No valid test here — we flag; see invalid section.
+
+ // === Edge cases we handle ===
+ // alt === src (we flag)
+ // numeric alt (we flag)
+ // redundant words (we flag)
+ ],
+ invalid: [
+ // === Upstream parity (invalid in jsx-a11y + ours) ===
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'imgMissing' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'inputImage' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'objectMissing' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'areaMissing' }],
+ },
+
+ // === DIVERGENCE —
without alt ===
+ // jsx-a11y: VALID. Ours: INVALID (imgMissing).
+ // Behavior captured here; potential false positive per WAI-ARIA.
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'imgMissing' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'imgMissing' }],
+ },
+
+ // === DIVERGENCE — non-empty alt with role=presentation on img ===
+ // jsx-a11y: VALID — accepts `
`.
+ // Ours: INVALID — imgRolePresentation. We're spec-strict: if role is
+ // "none"/"presentation", the image is decorative and alt should be empty.
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'imgRolePresentation' }],
+ },
+
+ // === Parity — empty-string aria-label/aria-labelledby ===
+ // jsx-a11y / vuejs-accessibility flag empty-string fallbacks on the
+ // "accessible-name-required" elements (