From 7ddd5087e1c9faf9bad2c5749e97f3b1275e5440 Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 5 Jul 2023 21:53:14 +0000 Subject: [PATCH 01/44] create rule: a11y-no-sr-only-class-when-focusable --- README.md | 47 ++++++------- .../a11y-no-sr-only-class-when-focusable.md | 41 ++++++++++++ lib/index.js | 1 + .../a11y-no-sr-only-class-when-focusable.js | 66 +++++++++++++++++++ tests/a11y-no-sr-only-class-when-focusable.js | 43 ++++++++++++ 5 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 docs/rules/a11y-no-sr-only-class-when-focusable.md create mode 100644 lib/rules/a11y-no-sr-only-class-when-focusable.js create mode 100644 tests/a11y-no-sr-only-class-when-focusable.js diff --git a/README.md b/README.md index 14aedd58..4bc4d84a 100644 --- a/README.md +++ b/README.md @@ -82,28 +82,29 @@ This config will be interpreted in the following way: πŸ”§ Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ ❌ Deprecated. -| NameΒ Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β  | Description | πŸ’Ό | πŸ”§ | ❌ | -| :----------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- | -| [a11y-aria-label-is-well-formatted](docs/rules/a11y-aria-label-is-well-formatted.md) | [aria-label] text should be formatted as you would visual text. | βš›οΈ | | | -| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | | ❌ | -| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` | βœ… | | | -| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | πŸ” | | | -| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | πŸ” | | | -| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | πŸ” | | | -| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | πŸ” | πŸ”§ | | -| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | πŸ” | | | -| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | πŸ” | | | -| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | πŸ” | | | -| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | πŸ” | | | -| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags | βœ… | | | -| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables | βœ… | | | -| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | πŸ” | | | -| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | πŸ” | πŸ”§ | | -| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises | βœ… | | | -| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | πŸ” | πŸ”§ | | -| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | πŸ” | | | -| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | πŸ” | | | -| [role-supports-aria-props](docs/rules/role-supports-aria-props.md) | Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`. | βš›οΈ | | | -| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | πŸ” | | | +| NameΒ Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β  | Description | πŸ’Ό | πŸ”§ | ❌ | +| :----------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- | +| [a11y-aria-label-is-well-formatted](docs/rules/a11y-aria-label-is-well-formatted.md) | [aria-label] text should be formatted as you would visual text. | βš›οΈ | | | +| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | | ❌ | +| [a11y-no-sr-only-class-when-focusable](docs/rules/a11y-no-sr-only-class-when-focusable.md) | Ensures that interactive elements are not visually hidden | | | | +| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` | βœ… | | | +| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | πŸ” | | | +| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | πŸ” | | | +| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | πŸ” | | | +| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | πŸ” | πŸ”§ | | +| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | πŸ” | | | +| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | πŸ” | | | +| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | πŸ” | | | +| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | πŸ” | | | +| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags | βœ… | | | +| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables | βœ… | | | +| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | πŸ” | | | +| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | πŸ” | πŸ”§ | | +| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises | βœ… | | | +| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | πŸ” | πŸ”§ | | +| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | πŸ” | | | +| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | πŸ” | | | +| [role-supports-aria-props](docs/rules/role-supports-aria-props.md) | Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`. | βš›οΈ | | | +| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | πŸ” | | | diff --git a/docs/rules/a11y-no-sr-only-class-when-focusable.md b/docs/rules/a11y-no-sr-only-class-when-focusable.md new file mode 100644 index 00000000..7712ddaf --- /dev/null +++ b/docs/rules/a11y-no-sr-only-class-when-focusable.md @@ -0,0 +1,41 @@ +# Ensures that interactive elements are not visually hidden (`github/a11y-no-sr-only-class-when-focusable`) + + + +## Rule Details + +This rule guards against visually hiding interactive elements. If a sighted keyboard user navigates to an interactive element that is visually hidden they might become confused and assume that keyboard focus has been lost. + +πŸ‘Ž Examples of **incorrect** code for this rule: + +```jsx + +``` + +```jsx + + + +``` + +```jsx +Submit +``` + +πŸ‘ Examples of **correct** code for this rule: + +```jsx +

Welcome to GitHub

+``` + +```jsx + +

Welcome to GitHub

+
+``` + +```jsx +Welcome to GitHub +``` + +## Version diff --git a/lib/index.js b/lib/index.js index c4a8b3ee..f12b80e4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,6 @@ module.exports = { rules: { + 'a11y-no-sr-only-class-when-focusable': require('./rules/a11y-no-sr-only-class-when-focusable'), 'a11y-no-generic-link-text': require('./rules/a11y-no-generic-link-text'), 'a11y-aria-label-is-well-formatted': require('./rules/a11y-aria-label-is-well-formatted'), 'array-foreach': require('./rules/array-foreach'), diff --git a/lib/rules/a11y-no-sr-only-class-when-focusable.js b/lib/rules/a11y-no-sr-only-class-when-focusable.js new file mode 100644 index 00000000..663b9f36 --- /dev/null +++ b/lib/rules/a11y-no-sr-only-class-when-focusable.js @@ -0,0 +1,66 @@ +const {getProp, getPropValue} = require('jsx-ast-utils') +const {getElementType} = require('../utils/get-element-type') + +const INTERACTIVELEMENTS = ['a', 'button', 'summary', 'select', 'input', 'option', 'textarea'] + +const checkIfInteractiveElement = (context, node) => { + const elementType = getElementType(context, node.openingElement) + const asProp = getPropValue(getProp(node.openingElement.attributes, 'as')) + + for (const interactiveElement of INTERACTIVELEMENTS) { + if ((asProp ?? elementType) === interactiveElement) { + return true + } + } + return false +} + +// if the node is VisuallyHidden or Sr-only recursively check if it has interactive children +const checkIfVisuallyHiddenAndInteractive = (context, node, isParentVisuallyHidden) => { + if (node.type === 'JSXElement') { + const className = getPropValue(getProp(node.openingElement.attributes, 'className')) + const isVisuallyHiddenElement = node.openingElement.name.name === 'VisuallyHidden' + const hasSROnlyClass = typeof className !== 'undefined' && className.includes('sr-only') + let isHidden = false + + if (hasSROnlyClass || isVisuallyHiddenElement || !!isParentVisuallyHidden) { + if (checkIfInteractiveElement(context, node)) { + return true + } + isHidden = true + } + if (node.children && node.children.length > 0) { + return ( + typeof node.children?.find(child => + checkIfVisuallyHiddenAndInteractive(context, child, !!isParentVisuallyHidden || isHidden), + ) !== 'undefined' + ) + } + } + return false +} + +module.exports = { + meta: { + docs: { + description: 'Ensures that interactive elements are not visually hidden', + url: require('../url')(module), + }, + schema: [], + }, + + create(context) { + return { + JSXElement: node => { + if (checkIfVisuallyHiddenAndInteractive(context, node, false)) { + context.report({ + node, + message: + 'Avoid adding the "sr-only" class to interactive elements. Visually hiding interactive elements can be confusing to sighted keyboard users as it appears their focus has been lost when they navigate to an sr-only element.', + }) + return + } + }, + } + }, +} diff --git a/tests/a11y-no-sr-only-class-when-focusable.js b/tests/a11y-no-sr-only-class-when-focusable.js new file mode 100644 index 00000000..49cd949d --- /dev/null +++ b/tests/a11y-no-sr-only-class-when-focusable.js @@ -0,0 +1,43 @@ +const rule = require('../lib/rules/a11y-no-sr-only-class-when-focusable') +const RuleTester = require('eslint').RuleTester + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +const errorMessage = + 'Avoid adding the "sr-only" class to interactive elements. Visually hiding interactive elements can be confusing to sighted keyboard users as it appears their focus has been lost when they navigate to an sr-only element.' + +ruleTester.run('a11y-no-sr-only-class-when-focusable', rule, { + valid: [ + {code: 'Submit'}, + {code: "
Text
;"}, + {code: '
Text
'}, + {code: "
Text
;"}, + {code: "Text;"}, + {code: ""}, + {code: ''}, + ], + invalid: [ + {code: 'Submit', errors: [{message: errorMessage}]}, + {code: '', errors: [{message: errorMessage}]}, + { + code: '', + errors: [{message: errorMessage}], + }, + {code: "", errors: [{message: errorMessage}]}, + {code: '
', errors: [{message: errorMessage}]}, + {code: "GitHub", errors: [{message: errorMessage}]}, + {code: "Toggle open", errors: [{message: errorMessage}]}, + {code: "