Skip to content

Commit 7f6463e

Browse files
mattxwangljharb
authored andcommitted
[New] add anchor-ambiguous-text rule
1 parent 630116b commit 7f6463e

File tree

5 files changed

+242
-0
lines changed

5 files changed

+242
-0
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ configuration file by mapping each custom component name to a DOM element type.
112112
<!-- AUTO-GENERATED-CONTENT:START (LIST) -->
113113

114114
- [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.
115+
- [anchor-ambiguous-text](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-ambiguous-text.md): Enforce `<a>` text to not exactly match "click here", "here", "link", or "a link".
115116
- [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.
116117
- [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.
117118
- [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.
155156
| :--- | :--- | :--- |
156157
| [accessible-emoji](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/accessible-emoji.md) | off | off |
157158
| [alt-text](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/alt-text.md) | error | error |
159+
| [anchor-ambiguous-text](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-ambiguous-text.md) | off | off |
158160
| [anchor-has-content](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-has-content.md) | error | error |
159161
| [anchor-is-valid](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-is-valid.md) | error | error |
160162
| [aria-activedescendant-has-tabindex](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/aria-activedescendant-has-tabindex.md) | error | error |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/* eslint-env jest */
2+
/**
3+
* @fileoverview Enforce `<a>` text to not exactly match "click here", "here", "link", or "a link".
4+
* @author Matt Wang
5+
*/
6+
7+
// -----------------------------------------------------------------------------
8+
// Requirements
9+
// -----------------------------------------------------------------------------
10+
11+
import { RuleTester } from 'eslint';
12+
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
13+
import rule from '../../../src/rules/anchor-ambiguous-text';
14+
15+
// -----------------------------------------------------------------------------
16+
// Tests
17+
// -----------------------------------------------------------------------------
18+
19+
const ruleTester = new RuleTester();
20+
21+
const DEFAULT_AMBIGUOUS_WORDS = [
22+
'click here',
23+
'here',
24+
'link',
25+
'a link',
26+
'learn more',
27+
];
28+
29+
const expectedErrorGenerator = (words) => ({
30+
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.`,
31+
type: 'JSXOpeningElement',
32+
});
33+
34+
const expectedError = expectedErrorGenerator(DEFAULT_AMBIGUOUS_WORDS);
35+
36+
ruleTester.run('anchor-ambiguous-text', rule, {
37+
valid: [
38+
{ code: '<a>documentation</a>;' },
39+
{ code: '<a>${here}</a>;' },
40+
{ code: '<a aria-label="tutorial on using eslint-plugin-jsx-a11y">click here</a>;' },
41+
{ code: '<a><span aria-label="tutorial on using eslint-plugin-jsx-a11y">click here</span></a>;' },
42+
{
43+
code: '<a>click here</a>',
44+
options: [{
45+
words: ['disabling the defaults'],
46+
}],
47+
},
48+
{
49+
code: '<Link>documentation</Link>;',
50+
settings: { 'jsx-a11y': { components: { Link: 'a' } } },
51+
},
52+
{
53+
code: '<Link>${here}</Link>;',
54+
settings: { 'jsx-a11y': { components: { Link: 'a' } } },
55+
},
56+
{
57+
code: '<Link aria-label="tutorial on using eslint-plugin-jsx-a11y">click here</Link>;',
58+
settings: { 'jsx-a11y': { components: { Link: 'a' } } },
59+
},
60+
{
61+
code: '<Link>click here</Link>',
62+
options: [{
63+
words: ['disabling the defaults with components'],
64+
}],
65+
settings: { 'jsx-a11y': { components: { Link: 'a' } } },
66+
},
67+
].map(parserOptionsMapper),
68+
invalid: [
69+
{ code: '<a>here</a>;', errors: [expectedError] },
70+
{ code: '<a>HERE</a>;', errors: [expectedError] },
71+
{ code: '<a>click here</a>;', errors: [expectedError] },
72+
{ code: '<a>learn more</a>;', errors: [expectedError] },
73+
{ code: '<a>link</a>;', errors: [expectedError] },
74+
{ code: '<a>a link</a>;', errors: [expectedError] },
75+
{ code: '<a aria-label="click here">something</a>;', errors: [expectedError] },
76+
{ code: '<a> a link </a>;', errors: [expectedError] },
77+
{ code: '<a>a<i></i> link</a>;', errors: [expectedError] },
78+
{ code: '<a><i></i>a link</a>;', errors: [expectedError] },
79+
{ code: '<a><span>click</span> here</a>;', errors: [expectedError] },
80+
{ code: '<a><span> click </span> here</a>;', errors: [expectedError] },
81+
{ code: '<a><span aria-hidden>more text</span>learn more</a>;', errors: [expectedError] },
82+
{ code: '<a><span aria-hidden="true">more text</span>learn more</a>;', errors: [expectedError] },
83+
{ code: '<a><CustomElement>click</CustomElement> here</a>;', errors: [expectedError] },
84+
{
85+
code: '<Link>here</Link>',
86+
errors: [expectedError],
87+
settings: { 'jsx-a11y': { components: { Link: 'a' } } },
88+
},
89+
{
90+
code: '<a>a disallowed word</a>',
91+
errors: [expectedErrorGenerator(['a disallowed word'])],
92+
options: [{
93+
words: ['a disallowed word'],
94+
}],
95+
},
96+
].map(parserOptionsMapper),
97+
});

docs/rules/anchor-ambiguous-text.md

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# anchor-ambiguous-text
2+
3+
Enforces `<a>` values are not exact matches for the phrases "click here", "here", "link", "a link", or "learn more". Screenreaders announce tags as links/interactive, but rely on values for context. Ambiguous anchor descriptions do not provide sufficient context for users.
4+
5+
## Rule details
6+
7+
This rule takes one optional object argument with the parameter `words`.
8+
9+
```json
10+
{
11+
"rules": {
12+
"jsx-a11y/anchor-ambiguous-text": [2, {
13+
"words": ["click this"],
14+
}],
15+
}
16+
}
17+
```
18+
19+
The `words` option allows users to modify the strings that can be checked for in the anchor text. Useful for specifying other words in other languages. The default value is set by `DEFAULT_AMBIGUOUS_WORDS`:
20+
21+
```js
22+
const DEFAULT_AMBIGUOUS_WORDS = ['click here', 'here', 'link', 'a link', 'learn more'];
23+
```
24+
25+
If an element has the `aria-label` property, its value is used instead of the inner text. Note that the rule still disallows ambiguous `aria-label`s. This rule also skips over elements with `aria-hidden="true"`.
26+
27+
Note that this rule is case-insensitive and trims whitespace. It only looks for **exact matches**.
28+
29+
### Succeed
30+
```jsx
31+
<a>read this tutorial</a> // passes since it is not one of the disallowed words
32+
<a>${here}</a> // this is valid since 'here' is a variable name
33+
<a aria-label="tutorial on using eslint-plugin-jsx-a11y">click here</a> // the aria-label supersedes the inner text
34+
```
35+
36+
### Fail
37+
```jsx
38+
<a>here</a>
39+
<a>HERE</a>
40+
<a>click here</a>
41+
<a>link</a>
42+
<a>a link</a>
43+
<a> a link </a>
44+
<a><span> click </span> here</a> // goes through element children
45+
<a>a<i></i> link</a>
46+
<a><i></i>a link</a>
47+
<a><span aria-hidden="true">more text</span>learn more</a> // skips over elements with aria-hidden=true
48+
<a aria-label="click here">something</a> // the aria-label here is inaccessible
49+
```
50+
51+
## Accessibility guidelines
52+
53+
Ensure anchor tags describe the content of the link, opposed to simply describing them as a link.
54+
55+
Compare
56+
57+
```jsx
58+
<p><a href="#">click here</a> to read a tutorial by Foo Bar</p>
59+
```
60+
61+
which can be more concise and accessible with
62+
63+
```jsx
64+
<p>read <a href="#">a tutorial by Foo Bar</a></p>
65+
```
66+
67+
### Resources
68+
69+
1. [WebAIM, Hyperlinks](https://webaim.org/techniques/hypertext/)
70+
2. [Deque University, Link Checklist - 'Avoid "link" (or similar) in the link text'](https://dequeuniversity.com/checklists/web/links)

src/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module.exports = {
44
rules: {
55
'accessible-emoji': require('./rules/accessible-emoji'),
66
'alt-text': require('./rules/alt-text'),
7+
'anchor-ambiguous-text': require('./rules/anchor-ambiguous-text'),
78
'anchor-has-content': require('./rules/anchor-has-content'),
89
'anchor-is-valid': require('./rules/anchor-is-valid'),
910
'aria-activedescendant-has-tabindex': require('./rules/aria-activedescendant-has-tabindex'),
@@ -51,6 +52,7 @@ module.exports = {
5152
},
5253
rules: {
5354
'jsx-a11y/alt-text': 'error',
55+
'jsx-a11y/anchor-ambiguous-text': 'off', // TODO: error
5456
'jsx-a11y/anchor-has-content': 'error',
5557
'jsx-a11y/anchor-is-valid': 'error',
5658
'jsx-a11y/aria-activedescendant-has-tabindex': 'error',

src/rules/anchor-ambiguous-text.js

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @fileoverview Enforce anchor text to not exactly match 'click here', 'here', 'link', 'learn more', and user-specified words.
3+
* @author Matt Wang
4+
* @flow
5+
*/
6+
7+
// ----------------------------------------------------------------------------
8+
// Rule Definition
9+
// ----------------------------------------------------------------------------
10+
11+
import type { ESLintConfig, ESLintContext } from '../../flow/eslint';
12+
import { arraySchema, generateObjSchema } from '../util/schemas';
13+
import getAccessibleChildText from '../util/getAccessibleChildText';
14+
import getElementType from '../util/getElementType';
15+
16+
const DEFAULT_AMBIGUOUS_WORDS = [
17+
'click here',
18+
'here',
19+
'link',
20+
'a link',
21+
'learn more',
22+
];
23+
24+
const schema = generateObjSchema({
25+
words: arraySchema,
26+
});
27+
28+
export default ({
29+
meta: {
30+
docs: {
31+
url: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/anchor-ambiguous-text.md',
32+
description: 'Enforce `<a>` text to not exactly match "click here", "here", "link", or "a link".',
33+
},
34+
schema: [schema],
35+
},
36+
37+
create: (context: ESLintContext) => {
38+
const elementType = getElementType(context);
39+
40+
const typesToValidate = ['a'];
41+
42+
const options = context.options[0] || {};
43+
const { words = DEFAULT_AMBIGUOUS_WORDS } = options;
44+
const ambiguousWords = new Set(words);
45+
46+
return {
47+
JSXOpeningElement: (node) => {
48+
const nodeType = elementType(node);
49+
50+
// Only check anchor elements and custom types.
51+
if (typesToValidate.indexOf(nodeType) === -1) {
52+
return;
53+
}
54+
55+
const nodeText = getAccessibleChildText(node.parent, elementType);
56+
57+
if (!ambiguousWords.has(nodeText)) { // check the value
58+
return;
59+
}
60+
61+
context.report({
62+
node,
63+
message: 'Ambiguous text within anchor. Screenreader users rely on link text for context; the words "{{wordsList}}" are ambiguous and do not provide enough context.',
64+
data: {
65+
wordsList: words.join('", "'),
66+
},
67+
});
68+
},
69+
};
70+
},
71+
}: ESLintConfig);

0 commit comments

Comments
 (0)