Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions lib/rules/template-require-valid-alt-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,32 @@ 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 — 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) {
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;
Expand Down Expand Up @@ -166,7 +192,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' });
}

Expand All @@ -177,7 +205,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))
) {
Expand All @@ -189,7 +217,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' });
}

Expand Down
198 changes: 198 additions & 0 deletions tests/audit/alt-text/peer-parity.js
Original file line number Diff line number Diff line change
@@ -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) ===
'<template><img alt="foo" /></template>',
'<template><img alt="" /></template>',
'<template><img alt=" " /></template>',
'<template><img alt="" role="presentation" /></template>',
'<template><img alt="" role="none" /></template>',
// DIVERGENCE — moved to invalid below:
// '<template><img alt="this is lit..." role="presentation" /></template>',
'<template><img alt={{@dynamicAlt}} /></template>',
// object with label/children
'<template><object aria-label="foo" /></template>',
'<template><object aria-labelledby="id1" /></template>',
'<template><object>Foo</object></template>',
'<template><object title="An object" /></template>',
// area with label
'<template><area aria-label="foo" /></template>',
'<template><area aria-labelledby="id1" /></template>',
'<template><area alt="foo" /></template>',
// input[type=image]
'<template><input type="image" alt="foo" /></template>',
'<template><input type="image" aria-label="foo" /></template>',
'<template><input type="image" aria-labelledby="id1" /></template>',

// === DIVERGENCE — aria-label/aria-labelledby on <img> without alt ===
// jsx-a11y: VALID — `<img aria-label="foo" />` is accepted.
// vue-a11y: VALID — same.
// Our rule: INVALID — requires `alt` attribute on <img>, full stop.
// Spec reading: the HTML spec mandates alt on <img>. 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: '<template><img /></template>',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
{
code: '<template><input type="image" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><object /></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><area /></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},

// === DIVERGENCE — <img aria-label> without alt ===
// jsx-a11y: VALID. Ours: INVALID (imgMissing).
// Behavior captured here; potential false positive per WAI-ARIA.
{
code: '<template><img aria-label="foo" /></template>',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
{
code: '<template><img aria-labelledby="id1" /></template>',
output: null,
errors: [{ messageId: 'imgMissing' }],
},

// === DIVERGENCE — non-empty alt with role=presentation on img ===
// jsx-a11y: VALID — accepts `<img alt="this is lit..." role="presentation" />`.
// Ours: INVALID — imgRolePresentation. We're spec-strict: if role is
// "none"/"presentation", the image is decorative and alt should be empty.
{
code: '<template><img alt="this is lit..." role="presentation" /></template>',
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 (<object>, <area>, <input
// type=image>). Our rule now reuses the existing messageIds.
{
code: '<template><object aria-label="" /></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><object aria-labelledby="" /></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><area aria-label="" /></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
{
code: '<template><area aria-labelledby="" /></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
{
code: '<template><input type="image" aria-label="" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><input type="image" aria-labelledby="" /></template>',
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: [
'<img alt="foo" />',
'<img alt="" />',
'<img alt="" role="presentation" />',
'<object aria-label="foo" />',
'<area aria-label="foo" />',
'<input type="image" aria-label="foo" />',
],
invalid: [
{
code: '<img />',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
{
code: '<input type="image" />',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<object />',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<area />',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
// DIVERGENCE captured — we flag img-with-aria-label (jsx-a11y/vue-a11y don't)
{
code: '<img aria-label="foo" />',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
// Parity — empty-string label on accessible-name-required elements.
{
code: '<object aria-label="" />',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
],
});
63 changes: 63 additions & 0 deletions tests/lib/rules/template-require-valid-alt-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,69 @@ ruleTester.run('template-require-valid-alt-text', rule, {
'<template><img role={{unless this.altText "presentation"}} alt={{this.altText}}></template>',
],
invalid: [
// Empty-string aria-label / aria-labelledby / alt provides no accessible
// name, so these must flag.
{
code: '<template><input type="image" aria-label="" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><input type="image" aria-labelledby="" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><input type="image" alt="" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><object aria-label=""></object></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><object aria-labelledby=""></object></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><object title=""></object></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><area aria-label=""></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
{
code: '<template><area aria-labelledby=""></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
{
code: '<template><area alt=""></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
// Whitespace-only values are not valid accessible names per ACCNAME 1.2 §4.3.2 step 2D.
{
code: '<template><input type="image" aria-label=" " /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><object aria-labelledby="\n\t" ></object></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><area href="/" title=" " /></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
{
code: '<template><img src="/test.jpg" /></template>',
output: null,
Expand Down
Loading