From 2337221a48edd6ff666baf239f303babd2fdf5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 07:50:06 +0200 Subject: [PATCH 1/6] fix(template-require-valid-alt-text): reject empty-string aria-label/aria-labelledby/alt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: for , , and , the rule checked only for the PRESENCE of an accessible-name fallback attribute (aria-label / aria-labelledby / alt / title). An empty-string value provides no accessible name but slipped past. Fix: add hasNonEmptyTextAttr() that requires the attribute's static value to be non-whitespace. Dynamic values (mustache, concat) remain accepted — we can't tell at lint time whether they resolve to empty. 's alt handling is unchanged — alt="" is still valid there (spec-defined marker for decorative images). Nine new invalid tests cover the three elements × three fallback attrs. --- lib/rules/template-require-valid-alt-text.js | 28 +++++++++-- .../rules/template-require-valid-alt-text.js | 47 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index 7e7193c936..83febe0f0b 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -12,6 +12,26 @@ function hasAnyAttr(node, names) { return names.some((name) => hasAttr(node, name)); } +// For accessible-name fallback attributes (aria-label, aria-labelledby, title), +// an empty string provides no accessible name — it must be checked as "any truthy +// static value" or "any dynamic value". Returns true iff the attribute is +// present AND will meaningfully contribute an accessible name. +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 +186,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 +199,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 +211,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/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index be3b6d4969..d4bc426353 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -59,6 +59,53 @@ ruleTester.run('template-require-valid-alt-text', rule, { '', ], invalid: [ + // Empty-string aria-label / aria-labelledby / alt provides no accessible + // name. These must flag (previously accepted by a presence-only check). + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, { code: '', output: null, From 7488d8dd1a14b378f03f019f87d25d3492178e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 16:29:08 +0200 Subject: [PATCH 2/6] chore: drop temporal 'previously accepted' comment --- tests/lib/rules/template-require-valid-alt-text.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index d4bc426353..6a3723613e 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -60,7 +60,7 @@ ruleTester.run('template-require-valid-alt-text', rule, { ], invalid: [ // Empty-string aria-label / aria-labelledby / alt provides no accessible - // name. These must flag (previously accepted by a presence-only check). + // name, so these must flag. { code: '', output: null, From 56567ef73352f055076d56eea4bba79a020e20a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 17:52:28 +0200 Subject: [PATCH 3/6] test: add Phase 3 audit fixture translating alt-text peer cases Translates 41 cases from peer-plugin rules: - jsx-a11y alt-text - vuejs-accessibility alt-text - lit-a11y alt-text Fixture documents parity after this fix: - Empty-string aria-label/aria-labelledby on , , and is now flagged (reusing existing objectMissing / areaMissing / inputImage messageIds). Remaining divergences ( accepting non-empty alt in jsx-a11y, without alt) are annotated inline. --- tests/audit/alt-text/peer-parity.js | 190 ++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/audit/alt-text/peer-parity.js diff --git a/tests/audit/alt-text/peer-parity.js b/tests/audit/alt-text/peer-parity.js new file mode 100644 index 0000000000..77a371efcc --- /dev/null +++ b/tests/audit/alt-text/peer-parity.js @@ -0,0 +1,190 @@ +// Audit fixture — peer-plugin parity for `ember/template-require-valid-alt-text`. +// 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 `this is lit...`. + // 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 (, , ). Our rule now reuses the existing messageIds. + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:alt-text (hbs)', rule, { + valid: [ + 'foo', + '', + '', + '', + '', + '', + ], + invalid: [ + { + 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 captured — we flag img-with-aria-label (jsx-a11y/vue-a11y don't) + { + code: '', + output: null, + errors: [{ messageId: 'imgMissing' }], + }, + // Parity — empty-string label on accessible-name-required elements. + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + ], +}); From 98e022d5f71ff3752544f4c85b25b6c35bd74149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 14:21:08 +0200 Subject: [PATCH 4/6] fix+docs+test: trim whitespace, tighten JSDoc, add whitespace-only coverage (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSDoc for hasNonEmptyTextAttr() rewritten: no longer overstates the guarantee for dynamic values, and notes that aria-labelledby IDREFs are not validated. - Added invalid-case coverage for whitespace-only aria-label / aria-labelledby / title — ACCNAME 1.2 §4.3.2 step 2D. - hasNonEmptyTextAttr() already trims static values, so the new whitespace-only cases flag without further rule changes. --- lib/rules/template-require-valid-alt-text.js | 14 ++++++++++---- .../lib/rules/template-require-valid-alt-text.js | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index 83febe0f0b..db533a4e6a 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -12,10 +12,16 @@ function hasAnyAttr(node, names) { return names.some((name) => hasAttr(node, name)); } -// For accessible-name fallback attributes (aria-label, aria-labelledby, title), -// an empty string provides no accessible name — it must be checked as "any truthy -// static value" or "any dynamic value". Returns true iff the attribute is -// present AND will meaningfully contribute an accessible 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 — per ACCNAME 1.2 + * §4.3.2 step 2D. + * + * 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) { diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index 6a3723613e..0f497b015f 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -106,6 +106,22 @@ ruleTester.run('template-require-valid-alt-text', rule, { output: null, errors: [{ messageId: 'areaMissing' }], }, + // Whitespace-only values are not valid accessible names per ACCNAME 1.2 §4.3.2 step 2D. + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, { code: '', output: null, From 82022bbd1395e049dc1d4dfc139815a262785acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 14:22:09 +0200 Subject: [PATCH 5/6] docs: correct audit-fixture CI-run claim (Copilot review) --- tests/audit/alt-text/peer-parity.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/audit/alt-text/peer-parity.js b/tests/audit/alt-text/peer-parity.js index 77a371efcc..d0ca3661a6 100644 --- a/tests/audit/alt-text/peer-parity.js +++ b/tests/audit/alt-text/peer-parity.js @@ -1,4 +1,12 @@ -// Audit fixture — peer-plugin parity for `ember/template-require-valid-alt-text`. +// 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: From bc642188bc45abfc651e4d5055de70dc97ccf459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 23 Apr 2026 21:21:43 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(template-require-valid-alt-text):=20add?= =?UTF-8?q?ress=20Copilot=20review=20=E2=80=94=20JSDoc=20scope,=20area=20f?= =?UTF-8?q?allback=20test,=20HBS=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rules/template-require-valid-alt-text.js | 6 ++- .../rules/template-require-valid-alt-text.js | 37 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index db533a4e6a..a08b160bb9 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -16,8 +16,10 @@ function hasAnyAttr(node, names) { * 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 — per ACCNAME 1.2 - * §4.3.2 step 2D. + * 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. diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index 0f497b015f..8482ba563f 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -117,8 +117,11 @@ ruleTester.run('template-require-valid-alt-text', rule, { output: null, errors: [{ messageId: 'objectMissing' }], }, + // : title is NOT one of the accepted fallbacks per ACCNAME. + // Only alt / aria-label / aria-labelledby contribute. A whitespace-only + // aria-label should be flagged. { - code: '', + code: '', output: null, errors: [{ messageId: 'areaMissing' }], }, @@ -327,6 +330,38 @@ hbsRuleTester.run('template-require-valid-alt-text', rule, { }, ], }, + // HBS parity: empty / whitespace-only accessible-name fallbacks + // should be flagged, mirroring the GTS cases above. + { + code: '', + output: null, + errors: [ + { + message: + 'All elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: + 'Embedded elements must have alternative text by providing inner text, aria-label or aria-labelledby attributes.', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: + 'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.', + }, + ], + }, { code: 'picture', output: null,