From 7f6463e5cffd1faa5cf22e3b0d33465e22bd10e1 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 9 Jul 2022 00:56:20 +0000 Subject: [PATCH] [New] add `anchor-ambiguous-text` rule --- README.md | 2 + .../src/rules/anchor-ambiguous-text-test.js | 97 +++++++++++++++++++ docs/rules/anchor-ambiguous-text.md | 70 +++++++++++++ src/index.js | 2 + src/rules/anchor-ambiguous-text.js | 71 ++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 __tests__/src/rules/anchor-ambiguous-text-test.js create mode 100644 docs/rules/anchor-ambiguous-text.md create mode 100644 src/rules/anchor-ambiguous-text.js 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: 'learn more;', errors: [expectedError] }, + { code: 'click here;', errors: [expectedError] }, + { + code: 'here', + errors: [expectedError], + settings: { 'jsx-a11y': { components: { Link: 'a' } } }, + }, + { + code: 'a disallowed word', + errors: [expectedErrorGenerator(['a disallowed word'])], + options: [{ + words: ['a disallowed word'], + }], + }, + ].map(parserOptionsMapper), +}); diff --git a/docs/rules/anchor-ambiguous-text.md b/docs/rules/anchor-ambiguous-text.md new file mode 100644 index 000000000..ae7bbd45d --- /dev/null +++ b/docs/rules/anchor-ambiguous-text.md @@ -0,0 +1,70 @@ +# anchor-ambiguous-text + +Enforces `` 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. + +## Rule details + +This rule takes one optional object argument with the parameter `words`. + +```json +{ + "rules": { + "jsx-a11y/anchor-ambiguous-text": [2, { + "words": ["click this"], + }], + } +} +``` + +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`: + +```js +const DEFAULT_AMBIGUOUS_WORDS = ['click here', 'here', 'link', 'a link', 'learn more']; +``` + +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"`. + +Note that this rule is case-insensitive and trims whitespace. It only looks for **exact matches**. + +### Succeed +```jsx +read this tutorial // passes since it is not one of the disallowed words +${here} // this is valid since 'here' is a variable name +click here // the aria-label supersedes the inner text +``` + +### Fail +```jsx +here +HERE +click here +link +a link + a link + click here // goes through element children +a link +a link +learn more // skips over elements with aria-hidden=true +something // the aria-label here is inaccessible +``` + +## Accessibility guidelines + +Ensure anchor tags describe the content of the link, opposed to simply describing them as a link. + +Compare + +```jsx +

click here to read a tutorial by Foo Bar

+``` + +which can be more concise and accessible with + +```jsx +

read a tutorial by Foo Bar

+``` + +### 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);