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 `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..8482ba563f 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -59,6 +59,72 @@ 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' }], + }, + // 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' }], + }, + // : 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: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, { code: '', output: null, @@ -264,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,