Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | |
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |

Expand Down
117 changes: 117 additions & 0 deletions docs/rules/template-require-valid-alt-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# ember/template-require-valid-alt-text

<!-- end auto-generated rule header -->

Enforce that all elements that require alternative text have meaningful information to relay back to the end user. This is a critical component of accessibility for screenreader users in order for them to understand the content's purpose on the page. By default, this rule checks for alternative text on the following elements: `<img>`, `<area>`, `<input type="image">`, and `<object>`.

Enforce `img` alt attribute does not contain the word image, picture, or photo. Screen readers already announce `img` elements as an image. There is no need to use words such as _image_, _photo_, and/or _picture_. The rule will first check if `aria-hidden` is true to determine whether to enforce the rule. If the image is hidden, then rule will always succeed.

## Examples

This rule **forbids** the following:

### `<img>`

An `<img>` must have the `alt` attribute. It must have either meaningful text, or be an empty string.

The content of an `alt` attribute is used to calculate the machine-readable label of an element, whereas the text content is used to produce a label for the element. For this reason, adding a label to an icon can produce a confusing or duplicated label on a control that already has appropriate text content.

If it's not a meaningful image, it should have an empty alt attribute value and have the role of presentation or none.

`img` alt attribute does not contain the word image, picture, or photo. Screen readers already announce `img` elements as an image. There is no need to use words such as _image_, _photo_, _logo_, _spacer_, and/or _picture_.

Numbers are not considered valid alt text, and this rule disallows using only numbers in alt text.

This rule **forbids** the following:

```gjs
<template>
<img src='rwjblue.png' />
<img src='foo' alt='Photo of foo being weird.' />
<img src='foo' alt='YourCompany logo' />
<img src='bar' alt='Image of me at a bar!' />
<img src='baz' alt='Picture of baz fixing a bug.' />
<img src='b52.jpg' alt='52' />
<img src='foo' alt='foo as a banana' role='presentation' />
</template>
```

This rule **allows** the following:

```gjs
<template>
<img
src='rwjblue.png'
alt='A man standing in front of a room of people, giving a presentation about Ember.'
/>
<img src='foo' alt='YourCompany Home Page' />
<img src='bar' aria-hidden='true' alt='Picture of me taking a photo of an image' />
// Will pass because it is hidden.
<img src='baz' alt='Baz taking a {{photo}}' />
// This is valid since photo is a variable name.
<img src='b52.jpg' alt='b52 bomber jet' />
<img src='foo' alt='' role='presentation' />
// This is valid because it has a role of presentation.
</template>
```

### `<object>`

Add alternative text to all embedded `<object>` elements using either inner text, setting the `title` prop, or using the `aria-label` or `aria-labelledby` props.

Note, the `title` prop is generally less reliable than the alternatives. Some screen readers will not read this value aloud, leaving no description of the non-text content.

This rule **forbids** the following:

```gjs
<template><object width='128' height='256'></object></template>
```

This rule **allows** the following:

```gjs
<template>
<object width='128' height='256' title='Middle-sized'></object>
<object width='128' height='256' aria-label='Middle-sized'></object>
<object width='128' height='256' aria-labelledby='id-12345'></object>
</template>
```

### `<input type="image">`

All `<input type="image">` elements must have a non-empty `alt` prop set with a meaningful description of the image or have the `aria-label` or `aria-labelledby` props set.

This rule **forbids** the following:

```gjs
<template><input type='image' /></template>
```

This rule **allows** the following:

```gjs
<template><input type='image' alt='Select image to upload' /></template>
```

### `<area>`

All clickable `<area>` elements within an image map have an `alt`, `aria-label` or `aria-labelledby` prop that describes the purpose of the link.

This rule **forbids** the following:

```gjs
<template><area shape='poly' coords='113,24,211,0' href='inform.html' /></template>
```

This rule **allows** the following:

```gjs
<template><area shape='poly' coords='113,24,211,0' href='inform.html' alt='Inform' /></template>
```

## References

- [WCAG Technique- using alt attributes on img elements](https://www.w3.org/TR/WCAG20-TECHS/H37.html)
- [WCAG Criterion 1.1.1 - Non-text Content](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html)
- [HTML 5.2 spec - the img element](https://www.w3.org/TR/html5/semantics-embedded-content.html#the-img-element)
- [Failure of Success Criterion 1.1.1 due to providing a text alternative that is not null (e.g., alt="spacer" or alt="image") for images that should be ignored by assistive technology](https://www.w3.org/WAI/WCAG21/Techniques/failures/F39)
203 changes: 203 additions & 0 deletions lib/rules/template-require-valid-alt-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
const REDUNDANT_WORDS = ['image', 'photo', 'picture', 'logo', 'spacer'];

function findAttr(node, name) {
return node.attributes?.find((a) => a.name === name);
}

function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
}

function hasAnyAttr(node, names) {
return names.some((name) => hasAttr(node, name));
}

function getTextValue(attr) {
if (!attr?.value) {
return undefined;
}
if (attr.value.type === 'GlimmerTextNode') {
return attr.value.chars;
}
return undefined;
}

function getNormalizedAltText(altAttr) {
if (!altAttr?.value) {
return null;
}
if (altAttr.value.type === 'GlimmerTextNode') {
return altAttr.value.chars.trim().toLowerCase();
}
if (altAttr.value.type === 'GlimmerConcatStatement') {
const parts = (altAttr.value.parts || [])
.filter((p) => p.type === 'GlimmerTextNode')
.map((p) => p.chars)
.join(' ')
.trim()
.toLowerCase();
return parts === '' ? null : parts;
}
return null;
}

function hasChildren(node) {
return (
node.children &&
node.children.some((child) => {
if (child.type === 'GlimmerTextNode') {
return child.chars.trim().length > 0;
}
return true;
})
);
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require valid alt text for images and other elements',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-valid-alt-text.md',
templateMode: 'both',
},
schema: [],
messages: {
imgMissing: 'All `<img>` tags must have an alt attribute',
imgRedundant:
'Invalid alt attribute. Words such as `image`, `photo,` or `picture` are already announced by screen readers.',
imgAltEqualsSrc: 'The alt text must not be the same as the image source',
imgNumericAlt: 'A number is not valid alt text',
imgRolePresentation:
'The `alt` attribute should be empty if `<img>` has `role` of `none` or `presentation`',
inputImage:
'All <input> elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
objectMissing:
'Embedded <object> elements must have alternative text by providing inner text, aria-label or aria-labelledby attributes.',
areaMissing:
'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/require-valid-alt-text.js',
docs: 'docs/rule/require-valid-alt-text.md',
tests: 'test/unit/rules/require-valid-alt-text-test.js',
},
},
create(context) {
return {
// eslint-disable-next-line complexity
GlimmerElementNode(node) {
// Skip hidden elements
if (hasAttr(node, 'hidden')) {
return;
}

const ariaHidden = findAttr(node, 'aria-hidden');
if (ariaHidden) {
const val = getTextValue(ariaHidden);
if (val === 'true') {
return;
}
}

// Skip elements with ...attributes (splattributes)
if (hasAttr(node, '...attributes')) {
return;
}

const tag = node.tag;

switch (tag) {
case 'img': {
const altAttr = findAttr(node, 'alt');
const roleAttr = findAttr(node, 'role');
const srcAttr = findAttr(node, 'src');

// Check role=none/presentation with non-empty alt
if (altAttr && roleAttr) {
const roleValue = getTextValue(roleAttr);
const altValue = getTextValue(altAttr);
if (
roleValue &&
['none', 'presentation'].includes(roleValue.trim().toLowerCase()) &&
altValue !== ''
) {
context.report({ node, messageId: 'imgRolePresentation' });
}
}

if (!altAttr) {
context.report({ node, messageId: 'imgMissing' });
return;
}

// Check alt === src
const altValue = getTextValue(altAttr);
const srcValue = getTextValue(srcAttr);
if (altValue !== undefined && srcValue !== undefined && altValue === srcValue) {
context.report({ node, messageId: 'imgAltEqualsSrc' });
return;
}

// Check numeric-only alt and redundant words
const normalizedAlt = getNormalizedAltText(altAttr);
if (normalizedAlt !== null) {
if (/^\d+$/.test(normalizedAlt)) {
context.report({ node, messageId: 'imgNumericAlt' });
} else {
const words = normalizedAlt.split(' ');
const hasRedundant = REDUNDANT_WORDS.some((w) => words.includes(w));
if (hasRedundant) {
context.report({ node, messageId: 'imgRedundant' });
}
}
}

break;
}
case 'input': {
// Only check input type="image"
const typeAttr = findAttr(node, 'type');
const typeVal = getTextValue(typeAttr);
if (typeVal !== 'image') {
return;
}

if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
context.report({ node, messageId: 'inputImage' });
}

break;
}
case 'object': {
const roleAttr = findAttr(node, 'role');
const roleValue = getTextValue(roleAttr);

if (
hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
hasChildren(node) ||
(roleValue && ['presentation', 'none'].includes(roleValue))
) {
return;
}

context.report({ node, messageId: 'objectMissing' });

break;
}
case 'area': {
if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
context.report({ node, messageId: 'areaMissing' });
}

break;
}
// No default
}
},
};
},
};
Loading
Loading