Skip to content

Commit c57b4e1

Browse files
authored
Feat/rule accessibility props requires accessible (#33)
* chore: create utils methods * feat: create rule accessibility-props-require-accessible * docs: update README and add readme for rule * feat: add breaking example in example-app * fix: extended isAccessible definition to "missing accessible prop" * feat: add auto-fix * fix: fix after review * fix: isAccessible when prop accessible is an expression
1 parent cb79370 commit c57b4e1

11 files changed

Lines changed: 316 additions & 7 deletions

File tree

example-app/.eslintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"rules": {
1111
"@bam.tech/require-named-effect": "error",
1212
"@bam.tech/image-requires-accessible-prop": "error",
13-
"@bam.tech/do-not-use-role-on-image": "error"
13+
"@bam.tech/do-not-use-role-on-image": "error",
14+
"@bam.tech/accessibility-props-require-accessible": "error"
1415
}
1516
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
2+
// Save without formatting: [⌘ + K] > [S]
3+
4+
// This should trigger an error breaking eslint-plugin-bam-custom-rules:
5+
// bam-custom-rules/accessibility-props-require-accessible
6+
7+
import { View } from "react-native";
8+
9+
export const MyComponent = () => {
10+
return (
11+
<>
12+
<View accessibilityRole="button" />
13+
<View accessibilityLabel="this is a label" />
14+
</>
15+
);
16+
};

packages/eslint-plugin/README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ This plugin exports some custom rules that you can optionally use in your projec
5757
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
5858
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
5959

60-
| Name | Description | 🔧 | 💡 |
61-
| :----------------------------------------------------------------------------- | :----------------------------------------------------- | :-- | :-- |
62-
| [do-not-use-role-on-image](docs/rules/do-not-use-role-on-image.md) | Disallow role prop on Image component | 🔧 | |
63-
| [image-requires-accessible-prop](docs/rules/image-requires-accessible-prop.md) | Require accessible prop on image components | 🔧 | 💡 |
64-
| [require-named-effect](docs/rules/require-named-effect.md) | Enforces the use of named functions inside a useEffect | | |
60+
| Name                                   | Description | 🔧 | 💡 |
61+
| :--------------------------------------------------------------------------------------------- | :------------------------------------------------------------ | :-- | :-- |
62+
| [accessibility-props-require-accessible](docs/rules/accessibility-props-require-accessible.md) | Requires accessible prop when accessibility props are defined | 🔧 | |
63+
| [do-not-use-role-on-image](docs/rules/do-not-use-role-on-image.md) | Disallow role prop on Image component | 🔧 | |
64+
| [image-requires-accessible-prop](docs/rules/image-requires-accessible-prop.md) | Require accessible prop on image components | 🔧 | 💡 |
65+
| [require-named-effect](docs/rules/require-named-effect.md) | Enforces the use of named functions inside a useEffect | | |
6566

6667
<!-- end auto-generated rules list -->
6768

@@ -74,7 +75,8 @@ To use a rule, just declare it in your `.eslintrc`:
7475
"rules": {
7576
"@bam.tech/require-named-effect": "error",
7677
"@bam.tech/image-requires-accessible-prop": "error",
77-
"@bam.tech/do-not-use-role-on-image": "error"
78+
"@bam.tech/do-not-use-role-on-image": "error",
79+
"@bam.tech/accessibility-props-require-accessible": "error"
7880
}
7981
}
8082
```
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Requires accessible prop when accessibility props are defined (`@bam.tech/accessibility-props-require-accessible`)
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Requires accessible prop when accessibility props are defined
8+
9+
## Rule Details
10+
11+
Examples of **incorrect** code for this rule:
12+
13+
```jsx
14+
<View role="button" accessible={false} />
15+
```
16+
17+
```jsx
18+
<View accessible={false}>
19+
<View accessibilityLabel="this is a label" />
20+
</View>
21+
```
22+
23+
Examples of **correct** code for this rule:
24+
25+
```jsx
26+
<View accessibilityRole="button" accessible />
27+
```
28+
29+
```jsx
30+
<View accessible>
31+
<View accessibilityLabel="this is a label" />
32+
</View>
33+
```
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* @fileoverview Requires accessible prop when accessibility props
3+
* @author Paul Briand
4+
*/
5+
"use strict";
6+
7+
const isAccessible = require("../utils/isAccessible");
8+
const hasPropAccessible = require("../utils/hasAccessibleProp");
9+
//------------------------------------------------------------------------------
10+
// Rule Definition
11+
//------------------------------------------------------------------------------
12+
13+
/** @type {import('eslint').Rule.RuleModule} */
14+
module.exports = {
15+
meta: {
16+
type: "problem", // `problem`, `suggestion`, or `layout`
17+
docs: {
18+
description:
19+
"Requires accessible prop when accessibility props are defined",
20+
recommended: false,
21+
url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/accessibility-props-require-accessible.md", // URL to the documentation page for this rule
22+
},
23+
fixable: "code", // Or `code` or `whitespace`
24+
schema: [], // Add a schema if the rule has options
25+
messages: {
26+
roleRequiresAccessible:
27+
"Requires accessible prop when role prop is defined",
28+
labelRequiresAccessible:
29+
"Requires accessible prop when label prop is defined",
30+
},
31+
},
32+
33+
create(context) {
34+
return {
35+
JSXOpeningElement(node) {
36+
if (!isAccessible(node)) {
37+
if (
38+
node.attributes.some((attribute) =>
39+
["role", "accessibilityRole"].includes(attribute.name.name)
40+
)
41+
) {
42+
if (!hasPropAccessible(node)) {
43+
context.report({
44+
node,
45+
messageId: "roleRequiresAccessible",
46+
fix: (fixer) => {
47+
const openingTagEnd = node.range[1];
48+
return fixer.insertTextBeforeRange(
49+
[openingTagEnd - (node.selfClosing ? 2 : 1), openingTagEnd],
50+
" accessible"
51+
);
52+
},
53+
});
54+
} else {
55+
context.report({
56+
node,
57+
messageId: "roleRequiresAccessible",
58+
});
59+
}
60+
}
61+
62+
if (!isAnyParentAccessible(node)) {
63+
if (
64+
node.attributes.some((attribute) =>
65+
[
66+
"accessibilityLabel",
67+
"accessibilityHint",
68+
"accessibilityLabelledBy",
69+
"aria-label",
70+
"aria-labelledby",
71+
].includes(attribute.name.name)
72+
)
73+
) {
74+
if (!hasPropAccessible(node)) {
75+
context.report({
76+
node,
77+
messageId: "labelRequiresAccessible",
78+
fix: (fixer) => {
79+
const openingTagEnd = node.range[1];
80+
return fixer.insertTextBeforeRange(
81+
[
82+
openingTagEnd - (node.selfClosing ? 2 : 1),
83+
openingTagEnd,
84+
],
85+
" accessible"
86+
);
87+
},
88+
});
89+
} else {
90+
context.report({
91+
node,
92+
messageId: "labelRequiresAccessible",
93+
});
94+
}
95+
}
96+
}
97+
}
98+
},
99+
};
100+
},
101+
};
102+
103+
const isAnyParentAccessible = (node) => {
104+
/* function applied to a JSXOpeningElement, its parent is the JSXElement.
105+
We have to look for the parent of the parent */
106+
if (node.parent.parent && node.parent.parent.type === "JSXElement") {
107+
if (isAccessible(node.parent.parent.openingElement)) return true;
108+
109+
return isAnyParentAccessible(node.parent.parent.openingElement);
110+
}
111+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module.exports = (element) => {
2+
if (element.type === "JSXOpeningElement") {
3+
return element.attributes.some((attribute) => {
4+
return (
5+
attribute.type === "JSXAttribute" &&
6+
attribute.name.name === "accessible"
7+
);
8+
});
9+
}
10+
return false;
11+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const isImage = require("./isImage");
2+
const isPressable = require("./isPressable");
3+
const isText = require("./isText");
4+
const isTextInput = require("./isTextInput");
5+
6+
module.exports = (element) => {
7+
if (element.type === "JSXOpeningElement") {
8+
for (const attribute of element.attributes) {
9+
if (
10+
attribute.type === "JSXAttribute" &&
11+
attribute.name.name === "accessible"
12+
) {
13+
if (attribute.value === null) return true;
14+
15+
if (attribute.value.type === "JSXExpressionContainer") {
16+
return attribute.value.expression.value;
17+
}
18+
}
19+
}
20+
21+
if (isPressable(element)) {
22+
return true;
23+
}
24+
25+
if (isImage(element)) {
26+
for (const attribute of element.attributes) {
27+
if (attribute.name.name === "alt") {
28+
if (attribute.value.type === "Literal") {
29+
return !!attribute.value.value;
30+
}
31+
32+
if (attribute.value.type === "JSXExpressionContainer")
33+
return !!attribute.value.expression.value;
34+
}
35+
}
36+
}
37+
if (isTextInput(element)) {
38+
return true;
39+
}
40+
41+
if (isText(element)) {
42+
return true;
43+
}
44+
}
45+
46+
return false;
47+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = (element) => {
2+
if (element.type === "JSXOpeningElement") {
3+
return element.attributes.some((attribute) => {
4+
return (
5+
attribute.type === "JSXAttribute" &&
6+
(attribute.name.name === "onPress" ||
7+
attribute.name.name === "handlePress")
8+
);
9+
});
10+
}
11+
12+
return false;
13+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module.exports = (element) => {
2+
if (element.type === "JSXOpeningElement") {
3+
if (element.name.type == "JSXIdentifier" && element.name.name === "Text") {
4+
return true; // <Text />
5+
}
6+
7+
if (
8+
element.name.type === "JSXMemberExpression" &&
9+
element.name.object.name === "Typography"
10+
) {
11+
return true; // <Typography.P1 />
12+
}
13+
}
14+
15+
return false;
16+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = (element) => {
2+
if (
3+
element.type === "JSXOpeningElement" &&
4+
element.name.type === "JSXIdentifier"
5+
) {
6+
return ["TextInput", "Input", "Textarea"].includes(element.name.name);
7+
}
8+
return false;
9+
};

0 commit comments

Comments
 (0)