Repository: ember-template-lint/ember-template-lint
Pull request #624 (2019-01-23 19:35:06)

Add require-valid-alt-text rule.
refs:
https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/src/rules
https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/alt-text.md

@rwjblue can you please specify proper a11y rules location?


Patch:
diff --git a/docs/rule/require-valid-alt-text.md b/docs/rule/require-valid-alt-text.md
new file mode 100644
index 00000000..2da0cec0
--- /dev/null
+++ b/docs/rule/require-valid-alt-text.md
@@ -0,0 +1,81 @@
+## require-valid-alt-text
+
+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>`.
+
+This rule **forbids** the following:
+
+### `<img>`
+
+An `<img>` must have the `alt` attribute. It must have either meaningful text, or be an empty string. If it is an empty string, the `<img>` element tag must also have `role="presentation"` or `role="none"`.
+
+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.
+
+This rule **forbids** the following:
+
+```hbs
+<img src="rwjblue.png">
+```
+
+This rule **allows** the following:
+
+```hbs
+<img src="rwjblue.png" alt="A man standing in front of a room of people, giving a presentation about Ember.">
+```
+
+### `<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.
+
+This rule **forbids** the following:
+
+```hbs
+<object width="128" height="256"></object>
+```
+
+This rule **allows** the following:
+
+```hbs
+<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>
+```
+
+### `<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:
+
+```hbs
+<input type="image">
+```
+
+This rule **allows** the following:
+
+```hbs
+<input type="image" alt="Select image to upload">
+```
+
+### `<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:
+
+```hbs
+<area shape="poly" coords="113,24,211,0" href="inform.html">
+```
+
+This rule **allows** the following:
+
+```hbs
+<area shape="poly" coords="113,24,211,0" href="inform.html" alt="Inform">
+```
+
+### References
+
+* See [WCAG Technique- using alt attributes on img elements](https://www.w3.org/TR/WCAG20-TECHS/H37.html)
+* Ref [WCAG Criterion 1.1.1 - Non-text Content](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html)
+* Red [HTML 5.2 spec - the img element](https://www.w3.org/TR/html5/semantics-embedded-content.html#the-img-element)
diff --git a/docs/rules.md b/docs/rules.md
index 0234df4c..99c37ddd 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -38,6 +38,7 @@
 * [self-closing-void-elements](rule/self-closing-void-elements.md)
 * [simple-unless](rule/simple-unless.md)
 * [style-concatenation](rule/style-concatenation.md)
+* [require-valid-alt-text](rule/require-valid-alt-text.md)
 * [table-groups](rule/table-groups.md)
 * [template-length](rule/template-length.md)
 
diff --git a/lib/helpers/ast-node-info.js b/lib/helpers/ast-node-info.js
index 2c1aa212..4fcc856f 100644
--- a/lib/helpers/ast-node-info.js
+++ b/lib/helpers/ast-node-info.js
@@ -50,11 +50,40 @@ function isBlockStatement(node) {
 
 function hasAttribute(node, attributeName) {
   var attribute = findAttribute(node, attributeName);
-
   return !!attribute;
 }
 
+function hasAttributeValue(node, attributeName, nodeAttributeValue) {
+  const attributeNode = findAttribute(node, attributeName);
+  if (!attributeNode) {
+    return false;
+  }
+  return attributeValue(attributeNode) === nodeAttributeValue;
+}
+
+function elementAttributeValue(node, attributeName) {
+  return attributeValue(findAttribute(node, attributeName));
+}
+
+function hasAnyAttribute(node, attributeNames) {
+  return attributeNames.map(name => hasAttribute(node, name)).some(exists => exists === true);
+}
+
+function attributeValue(attrNode) {
+  if (!attrNode) {
+    return;
+  }
+  if (attrNode.value && attrNode.value.type === 'TextNode') {
+    return attrNode.value.chars;
+  } else {
+    return attrNode.value;
+  }
+}
+
 function findAttribute(node, attributeName) {
+  if (!node.attributes || !node.attributes.length) {
+    return;
+  }
   for (var i = 0; i < node.attributes.length; i++) {
     var attribute = node.attributes[i];
 
@@ -64,6 +93,10 @@ function findAttribute(node, attributeName) {
   }
 }
 
+function isInputElement(node) {
+  return node.tag === 'input';
+}
+
 function isImgElement(node) {
   return node.tag === 'img';
 }
@@ -72,6 +105,14 @@ function isLinkElement(node) {
   return node.tag === 'a';
 }
 
+function isObjectElement(node) {
+  return node.tag === 'object';
+}
+
+function isAreaElement(node) {
+  return node.tag === 'area';
+}
+
 function childrenFor(node) {
   if (node.type === 'Program') {
     return node.body;
@@ -97,6 +138,10 @@ module.exports = {
   findAttribute: findAttribute,
   hasAttribute: hasAttribute,
   hasChildren: hasChildren,
+  hasAnyAttribute: hasAnyAttribute,
+  hasAttributeValue: hasAttributeValue,
+  attributeValue: attributeValue,
+  elementAttributeValue: elementAttributeValue,
   isBlockStatement: isBlockStatement,
   isCommentStatement: isCommentStatement,
   isConcatStatement: isConcatStatement,
@@ -106,6 +151,9 @@ module.exports = {
   isComponentNode: isComponentNode,
   isConfigurationHtmlComment: isConfigurationHtmlComment,
   isElementNode: isElementNode,
+  isInputElement: isInputElement,
+  isObjectElement: isObjectElement,
+  isAreaElement: isAreaElement,
   isImgElement: isImgElement,
   isLinkElement: isLinkElement,
   isMustacheStatement: isMustacheStatement,
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 296afac7..ea1cba28 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -2,6 +2,7 @@
 
 module.exports = {
   'attribute-indentation': require('./lint-attribute-indentation'),
+  'require-valid-alt-text': require('./lint-require-valid-alt-text'),
   'block-indentation': require('./lint-block-indentation'),
   'deprecated-each-syntax': require('./deprecations/lint-deprecated-each-syntax'),
   'deprecated-inline-view-helper': require('./deprecations/lint-deprecated-inline-view-helper'),
diff --git a/lib/rules/lint-require-valid-alt-text.js b/lib/rules/lint-require-valid-alt-text.js
new file mode 100644
index 00000000..3cb6a303
--- /dev/null
+++ b/lib/rules/lint-require-valid-alt-text.js
@@ -0,0 +1,103 @@
+'use strict';
+
+const AstNodeInfo = require('../helpers/ast-node-info');
+const Rule = require('./base');
+
+function hasAccessibleChild(node) {
+  return AstNodeInfo.hasChildren(node);
+}
+
+module.exports = class A11yLintAltText extends Rule {
+  logNode({ node, message }) {
+    return this.log({
+      message,
+      line: node.loc && node.loc.start.line,
+      column: node.loc && node.loc.start.column,
+      source: this.sourceForNode(node),
+    });
+  }
+  visitor() {
+    return {
+      ElementNode(node) {
+        if (AstNodeInfo.hasAttribute(node, 'hidden')) {
+          return;
+        }
+
+        if (AstNodeInfo.hasAttributeValue(node, 'aria-hidden', 'true')) {
+          return;
+        }
+
+        const isImg = AstNodeInfo.isImgElement(node);
+        const isObj = AstNodeInfo.isObjectElement(node);
+        const isInput = AstNodeInfo.isInputElement(node);
+        const isArea = AstNodeInfo.isAreaElement(node);
+
+        if (isImg) {
+          const hasAltAttribute = AstNodeInfo.hasAttribute(node, 'alt');
+          const altValue = AstNodeInfo.elementAttributeValue(node, 'alt');
+          const roleValue = AstNodeInfo.elementAttributeValue(node, 'role');
+
+          if (!hasAltAttribute) {
+            this.logNode({
+              message: 'img tags must have an alt attribute',
+              node,
+            });
+          } else {
+            if (altValue === '') {
+              if (['presentation', 'none'].includes(roleValue)) {
+                return;
+              }
+              this.logNode({
+                message:
+                  'if the `alt` attribute is present and the value is an empty string, `role="presentation"` or `role="none"` must be present',
+                node,
+              });
+            }
+          }
+        } else if (isInput) {
+          const isImageInput = AstNodeInfo.hasAttributeValue(node, 'type', 'image');
+          if (!isImageInput) {
+            return;
+          }
+          const hasValidAttributes = AstNodeInfo.hasAnyAttribute(node, [
+            'aria-label',
+            'aria-labelledby',
+            'alt',
+          ]);
+
+          if (!hasValidAttributes) {
+            this.logNode({
+              message:
+                '<input> elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
+              node,
+            });
+          }
+        } else if (isObj) {
+          const hasValidAttributes = AstNodeInfo.hasAnyAttribute(node, [
+            'aria-label',
+            'aria-labelledby',
+            'title',
+          ]);
+
+          if (hasValidAttributes || hasAccessibleChild(node)) {
+            return;
+          }
+
+          this.logNode({
+            message:
+              'Embedded <object> elements must have alternative text by providing inner text, aria-label or aria-labelledby attributes.',
+            node,
+          });
+        } else if (isArea) {
+          if (!AstNodeInfo.hasAnyAttribute(node, ['aria-label', 'aria-labelledby', 'alt'])) {
+            this.logNode({
+              message:
+                'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
+              node,
+            });
+          }
+        }
+      },
+    };
+  }
+};


Test patch:
diff --git a/test/unit/rules/lint-require-valid-alt-text-test.js b/test/unit/rules/lint-require-valid-alt-text-test.js
new file mode 100644
index 00000000..7b9de775
--- /dev/null
+++ b/test/unit/rules/lint-require-valid-alt-text-test.js
@@ -0,0 +1,133 @@
+'use strict';
+
+const generateRuleTests = require('../../helpers/rule-test-harness');
+
+generateRuleTests({
+  name: 'require-valid-alt-text',
+
+  config: true,
+
+  good: [
+    '<img alt="hullo">',
+    '<img alt={{foo}}>',
+    '<img alt="blah {{derp}}">',
+    '<img aria-hidden="true">',
+    '<img hidden>',
+    '<img alt="" role="none" src="zoey.jpg">',
+    '<img alt="" role="presentation" src="zoey.jpg">',
+    '<img alt="a stylized graphic of a female hamster" src="zoey.jpg">',
+
+    '<input type="image" alt="some-alt">',
+    '<input type="image" aria-labelledby="some-alt">',
+    '<input type="image" aria-label="some-alt">',
+    '<input type="image" hidden>',
+    '<input type="image" aria-hidden="true">',
+
+    '<object title="some-alt"></object>',
+    '<object hidden></object>',
+    '<object aria-hidden="true"></object>',
+    '<object aria-labelledby="some-alt"></object>',
+    '<object aria-label="some-alt"></object>',
+    '<object>some text</object>',
+
+    '<area alt="some-alt">',
+    '<area hidden>',
+    '<area aria-hidden="true">',
+    '<area aria-labelledby="some-alt">',
+    '<area aria-label="some-alt">',
+  ],
+
+  bad: [
+    {
+      template: '<img>',
+
+      result: {
+        message: 'img tags must have an alt attribute',
+        moduleId: 'layout.hbs',
+        source: '<img>',
+        line: 1,
+        column: 0,
+      },
+    },
+    {
+      template: '<img src="zoey.jpg">',
+
+      result: {
+        message: 'img tags must have an alt attribute',
+        moduleId: 'layout.hbs',
+        source: '<img src="zoey.jpg">',
+        line: 1,
+        column: 0,
+      },
+    },
+    {
+      template: '<img alt="" src="zoey.jpg">',
+
+      result: {
+        message:
+          'if the `alt` attribute is present and the value is an empty string, `role="presentation"` or `role="none"` must be present',
+        moduleId: 'layout.hbs',
+        source: '<img alt="" src="zoey.jpg">',
+        line: 1,
+        column: 0,
+      },
+    },
+    {
+      template: '<img alt src="zoey.jpg">',
+
+      result: {
+        message:
+          'if the `alt` attribute is present and the value is an empty string, `role="presentation"` or `role="none"` must be present',
+        moduleId: 'layout.hbs',
+        source: '<img alt src="zoey.jpg">',
+        line: 1,
+        column: 0,
+      },
+    },
+    {
+      template: '<input type="image">',
+
+      result: {
+        message:
+          '<input> elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
+        moduleId: 'layout.hbs',
+        source: '<input type="image">',
+        line: 1,
+        column: 0,
+      },
+    },
+    {
+      template: '<object></object>',
+      result: {
+        message:
+          'Embedded <object> elements must have alternative text by providing inner text, aria-label or aria-labelledby attributes.',
+        moduleId: 'layout.hbs',
+        source: '<object></object>',
+        line: 1,
+        column: 0,
+      },
+    },
+    {
+      template: '<object />',
+      result: {
+        message:
+          'Embedded <object> elements must have alternative text by providing inner text, aria-label or aria-labelledby attributes.',
+        moduleId: 'layout.hbs',
+        source: '<object />',
+        line: 1,
+        column: 0,
+      },
+    },
+    {
+      template: '<area>',
+      result: {
+        message:
+          'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.',
+        moduleId: 'layout.hbs',
+        source: '<area>',
+        line: 1,
+        column: 0,
+      },
+    },
+  ],
+});