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/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' }], + }, + ], +}); diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index be3b6d4969..6a3723613e 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, so these must flag. + { + 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,