Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit cc51b62

Browse filesBrowse files
authored
feat: role-supports-aria-props rule (no aria-label-misuse)
2 parents 3b7aefa + cd0a526 commit cc51b62
Copy full SHA for cc51b62
Expand file treeCollapse file tree

18 files changed

+1369
-83
lines changed

‎.devcontainer/devcontainer.json

Copy file name to clipboard
+21-23Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,30 @@
11
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
22
// https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node
33
{
4-
"name": "Node.js",
5-
"build": {
6-
"dockerfile": "Dockerfile",
7-
// Update 'VARIANT' to pick a Node version: 16, 14, 12.
8-
// Append -bullseye or -buster to pin to an OS version.
9-
// Use -bullseye variants on local arm64/Apple Silicon.
10-
"args": { "VARIANT": "16" }
11-
},
4+
"name": "Node.js",
5+
"build": {
6+
"dockerfile": "Dockerfile",
7+
// Update 'VARIANT' to pick a Node version: 16, 14, 12.
8+
// Append -bullseye or -buster to pin to an OS version.
9+
// Use -bullseye variants on local arm64/Apple Silicon.
10+
"args": {"VARIANT": "16"}
11+
},
1212

13-
// Set *default* container specific settings.json values on container create.
14-
"settings": {},
13+
// Set *default* container specific settings.json values on container create.
14+
"settings": {},
1515

16-
// Add the IDs of extensions you want installed when the container is created.
17-
"extensions": [
18-
"dbaeumer.vscode-eslint"
19-
],
16+
// Add the IDs of extensions you want installed when the container is created.
17+
"extensions": ["dbaeumer.vscode-eslint"],
2018

21-
// Use 'forwardPorts' to make a list of ports inside the container available locally.
22-
// "forwardPorts": [],
19+
// Use 'forwardPorts' to make a list of ports inside the container available locally.
20+
// "forwardPorts": [],
2321

24-
// Use 'postCreateCommand' to run commands after the container is created.
25-
// "postCreateCommand": "yarn install",
22+
// Use 'postCreateCommand' to run commands after the container is created.
23+
// "postCreateCommand": "yarn install",
2624

27-
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
28-
"remoteUser": "node",
29-
"features": {
30-
"git": "latest"
31-
}
25+
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
26+
"remoteUser": "node",
27+
"features": {
28+
"git": "latest"
29+
}
3230
}

‎.eslintrc.js

Copy file name to clipboardExpand all lines: .eslintrc.js
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
root: true,
33
parserOptions: {
4-
ecmaVersion: 2020,
4+
ecmaVersion: 13,
55
},
66
env: {
77
es6: true,

‎README.md

Copy file name to clipboardExpand all lines: README.md
+3-2Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ For each component, you may specify a `default` and/or `props`. `default` may ma
5454
"settings": {
5555
"github": {
5656
"components": {
57-
"Box": { "default": "p" },
58-
"Link": { "props": {"as": { "undefined": "a", "a": "a", "button": "button"}}},
57+
"Box": {"default": "p"},
58+
"Link": {"props": {"as": {"undefined": "a", "a": "a", "button": "button"}}}
5959
}
6060
}
6161
}
@@ -94,3 +94,4 @@ This config will be interpreted in the following way:
9494
#### Accessibility-focused rules (prefixed with a11y)
9595

9696
- [No Generic Link Text](./docs/rules/a11y-no-generic-link-text.md)
97+
- [Role Supports ARIA Props](./docs/rules/role-supports-aria-props.md)

‎docs/rules/a11y-no-generic-link-text.md

Copy file name to clipboardExpand all lines: docs/rules/a11y-no-generic-link-text.md
+7-3Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,18 @@ Please perform browser tests and spot checks:
6363
```
6464

6565
```jsx
66-
<a href="github.com/about" aria-label="Why dogs are awesome">Read more</a>
66+
<a href="github.com/about" aria-label="Why dogs are awesome">
67+
Read more
68+
</a>
6769
```
6870

6971
```jsx
70-
<a href="github.com/about" aria-describedby="element123">Read more</a>
72+
<a href="github.com/about" aria-describedby="element123">
73+
Read more
74+
</a>
7175
```
7276

73-
### **Correct** code for this rule 👍
77+
### **Correct** code for this rule 👍
7478

7579
```jsx
7680
<a href="github.com/about">Learn more about GitHub</a>

‎docs/rules/array-foreach.md

Copy file name to clipboardExpand all lines: docs/rules/array-foreach.md
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ for (const el of els) {
9393
```
9494

9595
Use [`entries()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/entries) to get access to the index:
96+
9697
```js
9798
for (const [i, el] of els.entries()) {
9899
el.name = `Element ${i}`

‎docs/rules/authenticity-token.md

Copy file name to clipboardExpand all lines: docs/rules/authenticity-token.md
+2-2Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ on('click', '.js-my-button', function (e) {
3333

3434
fetch(form.action, {
3535
method: form.method,
36-
body: new FormData(form)
36+
body: new FormData(form),
3737
}).then(function () {
3838
alert('Success!')
3939
})
@@ -52,7 +52,7 @@ An alternate, but less preferred approach is to include the a signed CSRF url in
5252
on('click', '.js-my-button', function (e) {
5353
csrfRequest(this.getAttribute('data-url'), {
5454
method: 'PUT',
55-
body: data
55+
body: data,
5656
}).then(function () {
5757
alert('Success!')
5858
})

‎docs/rules/no-useless-passive.md

Copy file name to clipboardExpand all lines: docs/rules/no-useless-passive.md
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ window.addEventListener(
2121
() => {
2222
console.log('Scroll event fired!')
2323
},
24-
{passive: true}
24+
{passive: true},
2525
)
2626
```
2727

‎docs/rules/prefer-observers.md

Copy file name to clipboardExpand all lines: docs/rules/prefer-observers.md
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ window.addEventListener('scroll', () => {
3434
const isIntersecting = checkIfElementIntersects(
3535
element.getBoundingClientRect(),
3636
window.innerHeight,
37-
document.clientHeight
37+
document.clientHeight,
3838
)
3939
element.classList.toggle('intersecting', isIntersecting)
4040
})

‎docs/rules/require-passive-events.md

Copy file name to clipboardExpand all lines: docs/rules/require-passive-events.md
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ window.addEventListener(
2828
() => {
2929
/* ... */
3030
},
31-
{passive: true}
31+
{passive: true},
3232
)
3333
```
3434

+74Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Role Supports ARIA Props
2+
3+
## Rule Details
4+
5+
This rule enforces that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`.
6+
7+
For example, this rule aims to discourage common misuse of the `aria-label` and `aria-labelledby` attribute. `aria-label` and `aria-labelledby` support is only guaranteed on interactive elements like `button` or `a`, or on static elements like `div` and `span` with a permitted `role`. This rule will allow `aria-label` and `aria-labelledby` usage on `div` and `span` elements if it set to a role other than the ones listed in [WSC: a list of ARIA roles which cannot be named](https://w3c.github.io/aria/#namefromprohibited). This rule will never permit usage of `aria-label` and `aria-labelledby` on `h1`, `h2`, `h3`, `h4`, `h5`, `h6`, `strong`, `i`, `p`, `b`, or `code`.
8+
9+
### "Help! I'm trying to set a tooltip on a static element and this rule flagged it!"
10+
11+
Please do not use tooltips on static elements. It is a highly discouraged, inaccessible pattern.
12+
See [Primer: Tooltip alternatives](https://primer.style/design/accessibility/tooltip-alternatives) for what to do instead.
13+
14+
### Resources
15+
16+
- [w3c/aria Consider prohibiting author naming certain roles #833](https://github.com/w3c/aria/issues/833)
17+
- [Not so short note on aria-label usage - Big Table Edition](https://html5accessibility.com/stuff/2020/11/07/not-so-short-note-on-aria-label-usage-big-table-edition/)
18+
- [Your tooltips are bogus](https://heydonworks.com/article/your-tooltips-are-bogus/)
19+
- [Primer: Tooltip alternatives](https://primer.style/design/accessibility/tooltip-alternatives)
20+
21+
### Disclaimer
22+
23+
There are conflicting resources and opinions on what elements should support these naming attributes. For now, this rule will operate under a relatively simple heuristic aimed to minimize false positives. This may have room for future improvements. Learn more at [W3C Name Calcluation](https://w3c.github.io/aria/#namecalculation).
24+
25+
### **Incorrect** code for this rule 👎
26+
27+
```erb
28+
<span class="tooltipped" aria-label="This is a tooltip">I am some text.</span>
29+
```
30+
31+
```erb
32+
<span aria-label="This is some content that will completely override the button content">Please be careful of the following.</span>
33+
```
34+
35+
```erb
36+
<div aria-labelledby="heading1">Goodbye</div>
37+
```
38+
39+
```erb
40+
<h1 aria-label="This will override the page title completely">Page title</h1>
41+
```
42+
43+
### **Correct** code for this rule 👍
44+
45+
```erb
46+
<button aria-label="Close">
47+
<svg src="closeIcon"></svg>
48+
</button>
49+
```
50+
51+
```erb
52+
<button aria-label="Bold" aria-describedby="tooltip1">
53+
<svg src="boldIcon"></svg>
54+
</button>
55+
<p id="tooltip1" class="tooltip">Add bold text or turn selection into bold text</p>
56+
```
57+
58+
```erb
59+
<span>Hello</span>
60+
```
61+
62+
```erb
63+
<div>Goodbye</div>
64+
```
65+
66+
```erb
67+
<h1>Page title</h1>
68+
```
69+
70+
```erb
71+
<div role="dialog" aria-labelledby="dialogHeading">
72+
<h1 id="dialogHeading">Heading</h1>
73+
</div>
74+
```

‎lib/configs/react.js

Copy file name to clipboardExpand all lines: lib/configs/react.js
+2Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ module.exports = {
99
extends: ['plugin:jsx-a11y/recommended'],
1010
rules: {
1111
'github/a11y-no-generic-link-text': 'error',
12+
'github/role-supports-aria-props': 'error',
13+
'jsx-a11y/role-supports-aria-props': 'off',
1214
},
1315
}

‎lib/index.js

Copy file name to clipboardExpand all lines: lib/index.js
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module.exports = {
1717
'no-then': require('./rules/no-then'),
1818
'no-useless-passive': require('./rules/no-useless-passive'),
1919
'prefer-observers': require('./rules/prefer-observers'),
20+
'role-supports-aria-props': require('./rules/role-supports-aria-props'),
2021
'require-passive-events': require('./rules/require-passive-events'),
2122
'unescaped-html-literal': require('./rules/unescaped-html-literal'),
2223
},

‎lib/rules/role-supports-aria-props.js

Copy file name to clipboard
+101Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// @ts-check
2+
const {aria, elementRoles, roles} = require('aria-query')
3+
const {getProp, getPropValue, propName} = require('jsx-ast-utils')
4+
const {getElementType} = require('../utils/get-element-type')
5+
const ObjectMap = require('../utils/object-map')
6+
7+
// Clean-up `elementRoles` from `aria-query`
8+
const elementRolesMap = new ObjectMap()
9+
for (const [key, value] of elementRoles.entries()) {
10+
// - Remove unused `constraints` key
11+
delete key.constraints
12+
key.attributes = key.attributes?.filter(attribute => !('constraints' in attribute))
13+
// - Remove empty `attributes` key
14+
if (!key.attributes || key.attributes?.length === 0) {
15+
delete key.attributes
16+
}
17+
elementRolesMap.set(key, value)
18+
}
19+
// - Remove insufficiently-disambiguated `menuitem` entry
20+
elementRolesMap.delete({name: 'menuitem'})
21+
// - Disambiguate `menuitem` and `menu` roles by `type`
22+
elementRolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'command'}]}, ['menuitem'])
23+
elementRolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'radio'}]}, ['menuitemradio'])
24+
elementRolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])
25+
elementRolesMap.set({name: 'menu', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])
26+
27+
module.exports = {
28+
meta: {
29+
docs: {
30+
description:
31+
'Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`.',
32+
url: require('../url')(module),
33+
},
34+
schema: [],
35+
},
36+
37+
create(context) {
38+
return {
39+
JSXOpeningElement(node) {
40+
// Assemble a key for looking-up the element’s role in the `elementRolesMap`
41+
// - Get the element’s name
42+
const key = {name: getElementType(context, node)}
43+
// - Get the element’s disambiguating attributes
44+
for (const prop of ['aria-expanded', 'type', 'size', 'role', 'href', 'multiple', 'scope']) {
45+
// - Only provide `aria-expanded` when it’s required for disambiguation
46+
if (prop === 'aria-expanded' && key.name !== 'summary') continue
47+
const value = getPropValue(getProp(node.attributes, prop))
48+
if (value) {
49+
if (!('attributes' in key)) {
50+
key.attributes = []
51+
}
52+
if (prop === 'href') {
53+
key.attributes.push({name: prop})
54+
} else {
55+
key.attributes.push({name: prop, value})
56+
}
57+
}
58+
}
59+
// Get the element’s explicit or implicit role
60+
const role = getPropValue(getProp(node.attributes, 'role')) ?? elementRolesMap.get(key)?.[0]
61+
62+
// Return early if role could not be determined
63+
if (!role) return
64+
65+
// Get allowed ARIA attributes:
66+
// - From the role itself
67+
let allowedProps = Object.keys(roles.get(role)?.props || {})
68+
// - From parent roles
69+
for (const parentRole of roles.get(role)?.superClass.flat() ?? []) {
70+
allowedProps = allowedProps.concat(Object.keys(roles.get(parentRole)?.props || {}))
71+
}
72+
// Dedupe, for performance
73+
allowedProps = Array.from(new Set(allowedProps))
74+
75+
// Get prohibited ARIA attributes:
76+
// - From the role itself
77+
let prohibitedProps = roles.get(role)?.prohibitedProps || []
78+
// - From parent roles
79+
for (const parentRole of roles.get(role)?.superClass.flat() ?? []) {
80+
prohibitedProps = prohibitedProps.concat(roles.get(parentRole)?.prohibitedProps || [])
81+
}
82+
// - From comparing allowed vs all ARIA attributes
83+
prohibitedProps = prohibitedProps.concat(aria.keys().filter(x => !allowedProps.includes(x)))
84+
// Dedupe, for performance
85+
prohibitedProps = Array.from(new Set(prohibitedProps))
86+
87+
for (const prop of node.attributes) {
88+
// Return early if prohibited ARIA attribute is set to an ignorable value
89+
if (getPropValue(prop) == null || prop.type === 'JSXSpreadAttribute') return
90+
91+
if (prohibitedProps?.includes(propName(prop))) {
92+
context.report({
93+
node,
94+
message: `The attribute ${propName(prop)} is not supported by the role ${role}.`,
95+
})
96+
}
97+
}
98+
},
99+
}
100+
},
101+
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.