diff --git a/README.md b/README.md
index bf75208b9..66f817238 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,7 @@ configuration file by mapping each custom component name to a DOM element type.
- [alt-text](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/alt-text.md): Enforce all elements that require alternative text have meaningful information to relay back to end user.
+- [anchor-ambiguous-text](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-ambiguous-text.md): Enforce `` text to not exactly match "click here", "here", "link", or "a link".
- [anchor-has-content](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-has-content.md): Enforce all anchors to contain accessible content.
- [anchor-is-valid](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-is-valid.md): Enforce all anchors are valid, navigable elements.
- [aria-activedescendant-has-tabindex](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/aria-activedescendant-has-tabindex.md): Enforce elements with aria-activedescendant are tabbable.
@@ -155,6 +156,7 @@ configuration file by mapping each custom component name to a DOM element type.
| :--- | :--- | :--- |
| [accessible-emoji](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/accessible-emoji.md) | off | off |
| [alt-text](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/alt-text.md) | error | error |
+| [anchor-ambiguous-text](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-ambiguous-text.md) | off | off |
| [anchor-has-content](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-has-content.md) | error | error |
| [anchor-is-valid](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-is-valid.md) | error | error |
| [aria-activedescendant-has-tabindex](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/aria-activedescendant-has-tabindex.md) | error | error |
diff --git a/__tests__/src/rules/anchor-ambiguous-text-test.js b/__tests__/src/rules/anchor-ambiguous-text-test.js
new file mode 100644
index 000000000..fbd52e9ce
--- /dev/null
+++ b/__tests__/src/rules/anchor-ambiguous-text-test.js
@@ -0,0 +1,97 @@
+/* eslint-env jest */
+/**
+ * @fileoverview Enforce `` text to not exactly match "click here", "here", "link", or "a link".
+ * @author Matt Wang
+ */
+
+// -----------------------------------------------------------------------------
+// Requirements
+// -----------------------------------------------------------------------------
+
+import { RuleTester } from 'eslint';
+import parserOptionsMapper from '../../__util__/parserOptionsMapper';
+import rule from '../../../src/rules/anchor-ambiguous-text';
+
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+const ruleTester = new RuleTester();
+
+const DEFAULT_AMBIGUOUS_WORDS = [
+ 'click here',
+ 'here',
+ 'link',
+ 'a link',
+ 'learn more',
+];
+
+const expectedErrorGenerator = (words) => ({
+ message: `Ambiguous text within anchor. Screenreader users rely on link text for context; the words "${words.join('", "')}" are ambiguous and do not provide enough context.`,
+ type: 'JSXOpeningElement',
+});
+
+const expectedError = expectedErrorGenerator(DEFAULT_AMBIGUOUS_WORDS);
+
+ruleTester.run('anchor-ambiguous-text', rule, {
+ valid: [
+ { code: 'documentation;' },
+ { code: '${here};' },
+ { code: 'click here;' },
+ { code: 'click here;' },
+ {
+ code: 'click here',
+ options: [{
+ words: ['disabling the defaults'],
+ }],
+ },
+ {
+ code: 'documentation;',
+ settings: { 'jsx-a11y': { components: { Link: 'a' } } },
+ },
+ {
+ code: '${here};',
+ settings: { 'jsx-a11y': { components: { Link: 'a' } } },
+ },
+ {
+ code: 'click here;',
+ settings: { 'jsx-a11y': { components: { Link: 'a' } } },
+ },
+ {
+ code: 'click here',
+ options: [{
+ words: ['disabling the defaults with components'],
+ }],
+ settings: { 'jsx-a11y': { components: { Link: 'a' } } },
+ },
+ ].map(parserOptionsMapper),
+ invalid: [
+ { code: 'here;', errors: [expectedError] },
+ { code: 'HERE;', errors: [expectedError] },
+ { code: 'click here;', errors: [expectedError] },
+ { code: 'learn more;', errors: [expectedError] },
+ { code: 'link;', errors: [expectedError] },
+ { code: 'a link;', errors: [expectedError] },
+ { code: 'something;', errors: [expectedError] },
+ { code: ' a link ;', errors: [expectedError] },
+ { code: 'a link;', errors: [expectedError] },
+ { code: 'a link;', errors: [expectedError] },
+ { code: 'click here;', errors: [expectedError] },
+ { code: ' click here;', errors: [expectedError] },
+ { code: 'more textlearn more;', errors: [expectedError] },
+ { code: ';', errors: [expectedError] },
+ { code: ' learn more
click here to read a tutorial by Foo Bar
+``` + +which can be more concise and accessible with + +```jsx + +``` + +### Resources + +1. [WebAIM, Hyperlinks](https://webaim.org/techniques/hypertext/) +2. [Deque University, Link Checklist - 'Avoid "link" (or similar) in the link text'](https://dequeuniversity.com/checklists/web/links) diff --git a/src/index.js b/src/index.js index 98d97b16b..ae4b56697 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ module.exports = { rules: { 'accessible-emoji': require('./rules/accessible-emoji'), 'alt-text': require('./rules/alt-text'), + 'anchor-ambiguous-text': require('./rules/anchor-ambiguous-text'), 'anchor-has-content': require('./rules/anchor-has-content'), 'anchor-is-valid': require('./rules/anchor-is-valid'), 'aria-activedescendant-has-tabindex': require('./rules/aria-activedescendant-has-tabindex'), @@ -51,6 +52,7 @@ module.exports = { }, rules: { 'jsx-a11y/alt-text': 'error', + 'jsx-a11y/anchor-ambiguous-text': 'off', // TODO: error 'jsx-a11y/anchor-has-content': 'error', 'jsx-a11y/anchor-is-valid': 'error', 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', diff --git a/src/rules/anchor-ambiguous-text.js b/src/rules/anchor-ambiguous-text.js new file mode 100644 index 000000000..3308ebec3 --- /dev/null +++ b/src/rules/anchor-ambiguous-text.js @@ -0,0 +1,71 @@ +/** + * @fileoverview Enforce anchor text to not exactly match 'click here', 'here', 'link', 'learn more', and user-specified words. + * @author Matt Wang + * @flow + */ + +// ---------------------------------------------------------------------------- +// Rule Definition +// ---------------------------------------------------------------------------- + +import type { ESLintConfig, ESLintContext } from '../../flow/eslint'; +import { arraySchema, generateObjSchema } from '../util/schemas'; +import getAccessibleChildText from '../util/getAccessibleChildText'; +import getElementType from '../util/getElementType'; + +const DEFAULT_AMBIGUOUS_WORDS = [ + 'click here', + 'here', + 'link', + 'a link', + 'learn more', +]; + +const schema = generateObjSchema({ + words: arraySchema, +}); + +export default ({ + meta: { + docs: { + url: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-ambiguous-text.md', + description: 'Enforce `` text to not exactly match "click here", "here", "link", or "a link".', + }, + schema: [schema], + }, + + create: (context: ESLintContext) => { + const elementType = getElementType(context); + + const typesToValidate = ['a']; + + const options = context.options[0] || {}; + const { words = DEFAULT_AMBIGUOUS_WORDS } = options; + const ambiguousWords = new Set(words); + + return { + JSXOpeningElement: (node) => { + const nodeType = elementType(node); + + // Only check anchor elements and custom types. + if (typesToValidate.indexOf(nodeType) === -1) { + return; + } + + const nodeText = getAccessibleChildText(node.parent, elementType); + + if (!ambiguousWords.has(nodeText)) { // check the value + return; + } + + context.report({ + node, + message: 'Ambiguous text within anchor. Screenreader users rely on link text for context; the words "{{wordsList}}" are ambiguous and do not provide enough context.', + data: { + wordsList: words.join('", "'), + }, + }); + }, + }; + }, +}: ESLintConfig);