Skip to content

Commit 7ddd508

Browse files
create rule: a11y-no-sr-only-class-when-focusable
1 parent dad26dc commit 7ddd508

5 files changed

+175
-23
lines changed

README.md

+24-23
Original file line numberDiff line numberDiff line change
@@ -82,28 +82,29 @@ This config will be interpreted in the following way:
8282
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
8383
❌ Deprecated.
8484

85-
| Name                              | Description | 💼 | 🔧 ||
86-
| :----------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- |
87-
| [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. | ⚛️ | | |
88-
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
89-
| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` || | |
90-
| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | |
91-
| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | |
92-
| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | |
93-
| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | |
94-
| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | |
95-
| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | |
96-
| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | |
97-
| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | |
98-
| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags || | |
99-
| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables || | |
100-
| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | |
101-
| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | |
102-
| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises || | |
103-
| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | |
104-
| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | |
105-
| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | |
106-
| [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`. | ⚛️ | | |
107-
| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | |
85+
| Name                                 | Description | 💼 | 🔧 ||
86+
| :----------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- |
87+
| [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. | ⚛️ | | |
88+
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
89+
| [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 | | | |
90+
| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` || | |
91+
| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | |
92+
| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | |
93+
| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | |
94+
| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | |
95+
| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | |
96+
| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | |
97+
| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | |
98+
| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | |
99+
| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags || | |
100+
| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables || | |
101+
| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | |
102+
| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | |
103+
| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises || | |
104+
| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | |
105+
| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | |
106+
| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | |
107+
| [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`. | ⚛️ | | |
108+
| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | |
108109

109110
<!-- end auto-generated rules list -->
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Ensures that interactive elements are not visually hidden (`github/a11y-no-sr-only-class-when-focusable`)
2+
3+
<!-- end auto-generated rule header -->
4+
5+
## Rule Details
6+
7+
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.
8+
9+
👎 Examples of **incorrect** code for this rule:
10+
11+
```jsx
12+
<button className="sr-only">Submit</button>
13+
```
14+
15+
```jsx
16+
<VisuallyHidden>
17+
<button>Submit</button>
18+
</VisuallyHidden>
19+
```
20+
21+
```jsx
22+
<VisuallyHidden as="button">Submit</VisuallyHidden>
23+
```
24+
25+
👍 Examples of **correct** code for this rule:
26+
27+
```jsx
28+
<h2 className="sr-only">Welcome to GitHub</h2>
29+
```
30+
31+
```jsx
32+
<VisuallyHidden>
33+
<h2>Welcome to GitHub</h2>
34+
</VisuallyHidden>
35+
```
36+
37+
```jsx
38+
<VisuallyHidden as="h2">Welcome to GitHub</VisuallyHidden>
39+
```
40+
41+
## Version

lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module.exports = {
22
rules: {
3+
'a11y-no-sr-only-class-when-focusable': require('./rules/a11y-no-sr-only-class-when-focusable'),
34
'a11y-no-generic-link-text': require('./rules/a11y-no-generic-link-text'),
45
'a11y-aria-label-is-well-formatted': require('./rules/a11y-aria-label-is-well-formatted'),
56
'array-foreach': require('./rules/array-foreach'),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const {getProp, getPropValue} = require('jsx-ast-utils')
2+
const {getElementType} = require('../utils/get-element-type')
3+
4+
const INTERACTIVELEMENTS = ['a', 'button', 'summary', 'select', 'input', 'option', 'textarea']
5+
6+
const checkIfInteractiveElement = (context, node) => {
7+
const elementType = getElementType(context, node.openingElement)
8+
const asProp = getPropValue(getProp(node.openingElement.attributes, 'as'))
9+
10+
for (const interactiveElement of INTERACTIVELEMENTS) {
11+
if ((asProp ?? elementType) === interactiveElement) {
12+
return true
13+
}
14+
}
15+
return false
16+
}
17+
18+
// if the node is VisuallyHidden or Sr-only recursively check if it has interactive children
19+
const checkIfVisuallyHiddenAndInteractive = (context, node, isParentVisuallyHidden) => {
20+
if (node.type === 'JSXElement') {
21+
const className = getPropValue(getProp(node.openingElement.attributes, 'className'))
22+
const isVisuallyHiddenElement = node.openingElement.name.name === 'VisuallyHidden'
23+
const hasSROnlyClass = typeof className !== 'undefined' && className.includes('sr-only')
24+
let isHidden = false
25+
26+
if (hasSROnlyClass || isVisuallyHiddenElement || !!isParentVisuallyHidden) {
27+
if (checkIfInteractiveElement(context, node)) {
28+
return true
29+
}
30+
isHidden = true
31+
}
32+
if (node.children && node.children.length > 0) {
33+
return (
34+
typeof node.children?.find(child =>
35+
checkIfVisuallyHiddenAndInteractive(context, child, !!isParentVisuallyHidden || isHidden),
36+
) !== 'undefined'
37+
)
38+
}
39+
}
40+
return false
41+
}
42+
43+
module.exports = {
44+
meta: {
45+
docs: {
46+
description: 'Ensures that interactive elements are not visually hidden',
47+
url: require('../url')(module),
48+
},
49+
schema: [],
50+
},
51+
52+
create(context) {
53+
return {
54+
JSXElement: node => {
55+
if (checkIfVisuallyHiddenAndInteractive(context, node, false)) {
56+
context.report({
57+
node,
58+
message:
59+
'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.',
60+
})
61+
return
62+
}
63+
},
64+
}
65+
},
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
const rule = require('../lib/rules/a11y-no-sr-only-class-when-focusable')
2+
const RuleTester = require('eslint').RuleTester
3+
4+
const ruleTester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 'latest',
7+
sourceType: 'module',
8+
ecmaFeatures: {
9+
jsx: true,
10+
},
11+
},
12+
})
13+
14+
const errorMessage =
15+
'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.'
16+
17+
ruleTester.run('a11y-no-sr-only-class-when-focusable', rule, {
18+
valid: [
19+
{code: '<VisuallyHidden as="h2">Submit</VisuallyHidden>'},
20+
{code: "<div className='sr-only'>Text</div>;"},
21+
{code: '<VisuallyHidden><div>Text</div></VisuallyHidden>'},
22+
{code: "<div className='other sr-only'>Text</div>;"},
23+
{code: "<span className='sr-only'>Text</span>;"},
24+
{code: "<button className='other'>Submit</button>"},
25+
{code: '<button>Submit</button>'},
26+
],
27+
invalid: [
28+
{code: '<VisuallyHidden as="button">Submit</VisuallyHidden>', errors: [{message: errorMessage}]},
29+
{code: '<VisuallyHidden><button>Submit</button></VisuallyHidden>', errors: [{message: errorMessage}]},
30+
{
31+
code: '<VisuallyHidden><button class="sr-only">Submit</button></VisuallyHidden>',
32+
errors: [{message: errorMessage}],
33+
},
34+
{code: "<button className='sr-only'>Submit</button>", errors: [{message: errorMessage}]},
35+
{code: '<VisuallyHidden><div><button>Submit</button></div></VisuallyHidden>', errors: [{message: errorMessage}]},
36+
{code: "<a className='other sr-only' href='github.com'>GitHub</a>", errors: [{message: errorMessage}]},
37+
{code: "<summary className='sr-only'>Toggle open</summary>", errors: [{message: errorMessage}]},
38+
{code: "<textarea className='sr-only' />", errors: [{message: errorMessage}]},
39+
{code: "<select className='sr-only' />", errors: [{message: errorMessage}]},
40+
{code: "<input className='sr-only' />", errors: [{message: errorMessage}]},
41+
{code: "<a className='sr-only'>Read more</a>", errors: [{message: errorMessage}]},
42+
],
43+
})

0 commit comments

Comments
 (0)