diff --git a/README.md b/README.md index 14aedd58..67bdfb84 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-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.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-visually-hidden-interactive-element.md b/docs/rules/a11y-no-visually-hidden-interactive-element.md new file mode 100644 index 00000000..24af1e8b --- /dev/null +++ b/docs/rules/a11y-no-visually-hidden-interactive-element.md @@ -0,0 +1,79 @@ +# Ensures that interactive elements are not visually hidden (`github/a11y-no-visually-hidden-interactive-element`) + +πŸ’Ό This rule is enabled in the βš›οΈ `react` config. + + + +## 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. + +Note: we are not guarding against visually hidden `input` elements at this time. Some visually hidden inputs might cause a false positive (e.g. some file inputs). + +### Why do we visually hide content? + +Visually hiding content can be useful when you want to provide information specifically to screen reader users or other assitive technology users while keeping content hidden from sighted users. + +Applying the following css will visually hide content while still making it accessible to screen reader users. + +```css +clip-path: inset(50%); +height: 1px; +overflow: hidden; +position: absolute; +white-space: nowrap; +width: 1px; +``` + +πŸ‘Ž 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 +``` + +## Options + +- className - A css className that visually hides content. Defaults to `sr-only`. +- componentName - A react component name that visually hides content. Defaults to `VisuallyHidden`. +- htmlPropName - A prop name used to replace the semantic element that is rendered. Defaults to `as`. + +```json +{ + "a11y-no-visually-hidden-interactive-element": [ + "error", + { + "className": "visually-hidden", + "componentName": "VisuallyHidden", + "htmlPropName": "as" + } + ] +} +``` + +## Version diff --git a/lib/configs/react.js b/lib/configs/react.js index 68eda59a..63021526 100644 --- a/lib/configs/react.js +++ b/lib/configs/react.js @@ -10,6 +10,7 @@ module.exports = { rules: { 'jsx-a11y/role-supports-aria-props': 'off', // Override with github/role-supports-aria-props until https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/910 is resolved 'github/a11y-aria-label-is-well-formatted': 'error', + 'github/a11y-no-visually-hidden-interactive-element': 'error', 'github/role-supports-aria-props': 'error', 'jsx-a11y/no-aria-hidden-on-focusable': 'error', 'jsx-a11y/no-autofocus': 'off', diff --git a/lib/index.js b/lib/index.js index c4a8b3ee..e3bd98a5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,6 @@ module.exports = { rules: { + 'a11y-no-visually-hidden-interactive-element': require('./rules/a11y-no-visually-hidden-interactive-element'), '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-visually-hidden-interactive-element.js b/lib/rules/a11y-no-visually-hidden-interactive-element.js new file mode 100644 index 00000000..bb2c1c42 --- /dev/null +++ b/lib/rules/a11y-no-visually-hidden-interactive-element.js @@ -0,0 +1,87 @@ +const {getProp, getPropValue} = require('jsx-ast-utils') +const {getElementType} = require('../utils/get-element-type') +const {generateObjSchema} = require('eslint-plugin-jsx-a11y/lib/util/schemas') + +const defaultClassName = 'sr-only' +const defaultcomponentName = 'VisuallyHidden' +const defaultHtmlPropName = 'as' + +const schema = generateObjSchema({ + className: {type: 'string'}, + componentName: {type: 'string'}, + htmlPropName: {type: 'string'}, +}) + +/** Note: we are not including input elements at this time + * because a visually hidden input field might cause a false positive. + * (e.g. fileUpload https://github.com/primer/react/pull/3492) + */ +const INTERACTIVELEMENTS = ['a', 'button', 'summary', 'select', 'option', 'textarea'] + +const checkIfInteractiveElement = (context, htmlPropName, node) => { + const elementType = getElementType(context, node.openingElement) + const asProp = getPropValue(getProp(node.openingElement.attributes, htmlPropName)) + + for (const interactiveElement of INTERACTIVELEMENTS) { + if ((asProp ?? elementType) === interactiveElement) { + return true + } + } + return false +} + +// if the node is visually hidden recursively check if it has interactive children +const checkIfVisuallyHiddenAndInteractive = (context, options, node, isParentVisuallyHidden) => { + const {className, componentName, htmlPropName} = options + if (node.type === 'JSXElement') { + const classes = getPropValue(getProp(node.openingElement.attributes, 'className')) + const isVisuallyHiddenElement = node.openingElement.name.name === componentName + const hasSROnlyClass = typeof classes !== 'undefined' && classes.includes(className) + let isHidden = false + if (hasSROnlyClass || isVisuallyHiddenElement || !!isParentVisuallyHidden) { + if (checkIfInteractiveElement(context, htmlPropName, node)) { + return true + } + isHidden = true + } + if (node.children && node.children.length > 0) { + return ( + typeof node.children?.find(child => + checkIfVisuallyHiddenAndInteractive(context, options, child, !!isParentVisuallyHidden || isHidden), + ) !== 'undefined' + ) + } + } + return false +} + +module.exports = { + meta: { + docs: { + description: 'Ensures that interactive elements are not visually hidden', + url: require('../url')(module), + }, + schema: [schema], + }, + + create(context) { + const {options} = context + const config = options[0] || {} + const className = config.className || defaultClassName + const componentName = config.componentName || defaultcomponentName + const htmlPropName = config.htmlPropName || defaultHtmlPropName + + return { + JSXElement: node => { + if (checkIfVisuallyHiddenAndInteractive(context, {className, componentName, htmlPropName}, node, false)) { + context.report({ + node, + message: + 'Avoid visually hidding 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 the hidden element.', + }) + return + } + }, + } + }, +} diff --git a/tests/a11y-no-visually-hidden-interactive-element.js b/tests/a11y-no-visually-hidden-interactive-element.js new file mode 100644 index 00000000..83c54401 --- /dev/null +++ b/tests/a11y-no-visually-hidden-interactive-element.js @@ -0,0 +1,97 @@ +const rule = require('../lib/rules/a11y-no-visually-hidden-interactive-element') +const RuleTester = require('eslint').RuleTester + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +const errorMessage = + 'Avoid visually hidding 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 the hidden element.' + +ruleTester.run('a11y-no-visually-hidden-interactive-element', rule, { + valid: [ + {code: 'Submit'}, + {code: "
Text
;"}, + {code: '
Text
'}, + {code: "
Text
;"}, + {code: "Text;"}, + {code: ""}, + {code: ""}, + {code: "skip to main content"}, + {code: ''}, + { + code: "", + options: [ + { + className: 'visually-hidden', + }, + ], + }, + { + code: "Submit", + options: [ + { + componentName: 'Hidden', + }, + ], + errors: [{message: errorMessage}], + }, + { + code: "Submit", + options: [ + { + htmlPropName: 'html', + }, + ], + }, + ], + 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: "