Skip to content

Commit efe4c95

Browse files
authored
Merge pull request #450 from github/lw/adds-svg-rule
Adds `svg-has-accessible-name` rule
2 parents 3844902 + 40089c3 commit efe4c95

File tree

6 files changed

+190
-24
lines changed

6 files changed

+190
-24
lines changed

README.md

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -81,29 +81,30 @@ This config will be interpreted in the following way:
8181
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
8282
❌ Deprecated.
8383

84-
| Name                                        | Description | 💼 | 🔧 ||
85-
| :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :-- | :-- | :-- |
86-
| [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. | ⚛️ | | |
87-
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
88-
| [a11y-no-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.md) | Ensures that interactive elements are not visually hidden | ⚛️ | | |
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 | 🔍 | | |
84+
| Name                                        | Description | 💼 | 🔧 ||
85+
| :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- |
86+
| [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. | ⚛️ | | |
87+
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
88+
| [a11y-no-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.md) | Ensures that interactive elements are not visually hidden | ⚛️ | | |
89+
| [a11y-svg-has-accessible-name](docs/rules/a11y-svg-has-accessible-name.md) | SVGs must have an accessible name | ⚛️ | | |
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 -->
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# SVGs must have an accessible name (`github/a11y-svg-has-accessible-name`)
2+
3+
💼 This rule is enabled in the ⚛️ `react` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
## Rule Details
8+
9+
An `<svg>` must have an accessible name. Set `aria-label` or `aria-labelledby`, or nest a `<title>` element as the first child of the `<svg>` element.
10+
11+
However, if the `<svg>` is purely decorative, hide it with `aria-hidden="true"` or `role="presentation"`.
12+
13+
## Resources
14+
15+
- [Accessible SVGs](https://css-tricks.com/accessible-svgs/)
16+
17+
## Examples
18+
19+
### **Incorrect** code for this rule 👎
20+
21+
```html
22+
<svg height='100' width='100'>
23+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
24+
</svg>
25+
```
26+
27+
```html
28+
<svg height='100' width='100' title='Circle with a black outline and red fill'>
29+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
30+
</svg>
31+
```
32+
33+
```html
34+
<svg height='100' width='100'>
35+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
36+
<title>Circle with a black outline and red fill</title>
37+
</svg>
38+
```
39+
40+
### **Correct** code for this rule 👍
41+
42+
```html
43+
<svg height='100' width='100'>
44+
<title>Circle with a black outline and red fill</title>
45+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
46+
</svg>
47+
```
48+
49+
```html
50+
<svg aria-label='Circle with a black outline and red fill' height='100' width='100'>
51+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
52+
</svg>
53+
```
54+
55+
```html
56+
<svg aria-labelledby='circle_text' height='100' width='100'>
57+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
58+
</svg>
59+
```
60+
61+
```html
62+
<svg aria-hidden='true' height='100' width='100'>
63+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
64+
</svg>
65+
```
66+
67+
```html
68+
<svg role='presentation' height='100' width='100'>
69+
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/>
70+
</svg>
71+
```
72+
73+
## Version

lib/configs/react.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
'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
1212
'github/a11y-aria-label-is-well-formatted': 'error',
1313
'github/a11y-no-visually-hidden-interactive-element': 'error',
14+
'github/a11y-svg-has-accessible-name': 'error',
1415
'github/role-supports-aria-props': 'error',
1516
'jsx-a11y/no-aria-hidden-on-focusable': 'error',
1617
'jsx-a11y/no-autofocus': 'off',

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module.exports = {
33
'a11y-no-visually-hidden-interactive-element': require('./rules/a11y-no-visually-hidden-interactive-element'),
44
'a11y-no-generic-link-text': require('./rules/a11y-no-generic-link-text'),
55
'a11y-aria-label-is-well-formatted': require('./rules/a11y-aria-label-is-well-formatted'),
6+
'a11y-svg-has-accessible-name': require('./rules/a11y-svg-has-accessible-name'),
67
'array-foreach': require('./rules/array-foreach'),
78
'async-currenttarget': require('./rules/async-currenttarget'),
89
'async-preventdefault': require('./rules/async-preventdefault'),
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const {hasProp} = require('jsx-ast-utils')
2+
const {getElementType} = require('../utils/get-element-type')
3+
4+
module.exports = {
5+
meta: {
6+
docs: {
7+
description: 'SVGs must have an accessible name',
8+
url: require('../url')(module),
9+
},
10+
schema: [],
11+
},
12+
13+
create(context) {
14+
return {
15+
JSXOpeningElement: node => {
16+
const elementType = getElementType(context, node)
17+
if (elementType !== 'svg') return
18+
19+
// Check if there is a nested title element that is the first child of the `<svg>`
20+
const hasNestedTitleAsFirstChild =
21+
node.parent.children?.[0]?.type === 'JSXElement' &&
22+
node.parent.children?.[0]?.openingElement?.name?.name === 'title'
23+
24+
// Check if `aria-label` or `aria-labelledby` is set
25+
const hasAccessibleName = hasProp(node.attributes, 'aria-label') || hasProp(node.attributes, 'aria-labelledby')
26+
27+
// Check if SVG is decorative
28+
const isDecorative =
29+
hasProp(node.attributes, 'role', 'presentation') || hasProp(node.attributes, 'aria-hidden', 'true')
30+
31+
if (elementType === 'svg' && !hasAccessibleName && !isDecorative && !hasNestedTitleAsFirstChild) {
32+
context.report({
33+
node,
34+
message:
35+
'`<svg>` must have an accessible name. Set `aria-label` or `aria-labelledby`, or nest a `<title>` element. However, if the `<svg>` is purely decorative, hide it with `aria-hidden="true"` or `role="presentation"`.',
36+
})
37+
}
38+
},
39+
}
40+
},
41+
}

tests/a11y-svg-has-accessible-name.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const rule = require('../lib/rules/a11y-svg-has-accessible-name')
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+
'`<svg>` must have an accessible name. Set `aria-label` or `aria-labelledby`, or nest a `<title>` element. However, if the `<svg>` is purely decorative, hide it with `aria-hidden="true"` or `role="presentation"`.'
16+
17+
ruleTester.run('a11y-svg-has-accessible-name', rule, {
18+
valid: [
19+
{
20+
code: "<svg height='100' width='100'><title>Circle with a black outline and red fill</title><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
21+
},
22+
{
23+
code: "<svg aria-label='Circle with a black outline and red fill' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
24+
},
25+
{
26+
code: "<svg aria-labelledby='circle_text' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
27+
},
28+
{
29+
code: "<svg aria-hidden='true' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
30+
},
31+
{
32+
code: "<svg role='presentation' height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
33+
},
34+
],
35+
invalid: [
36+
{
37+
code: "<svg height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
38+
errors: [{message: errorMessage}],
39+
},
40+
{
41+
code: "<svg height='100' width='100' title='Circle with a black outline and red fill'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/></svg>",
42+
errors: [{message: errorMessage}],
43+
},
44+
{
45+
code: "<svg height='100' width='100'><circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red'/><title>Circle with a black outline and red fill</title></svg>",
46+
errors: [{message: errorMessage}],
47+
},
48+
],
49+
})

0 commit comments

Comments
 (0)