eslint-plugin-primer-react 5.5.0-rc.40574df → 6.0.0-rc.2964cbe
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -1
- package/docs/rules/no-unnecessary-components.md +69 -0
- package/docs/rules/prefer-action-list-item-onselect.md +37 -0
- package/jest.config.js +7 -0
- package/package-lock.json +509 -45
- package/package.json +7 -3
- package/src/configs/recommended.js +2 -0
- package/src/index.js +2 -0
- package/src/rules/__tests__/fixtures/File.tsx +1 -0
- package/src/rules/__tests__/fixtures/file.ts +1 -0
- package/src/rules/__tests__/fixtures/tsconfig.json +7 -0
- package/src/rules/__tests__/no-unnecessary-components.test.js +153 -0
- package/src/rules/__tests__/prefer-action-list-item-onselect.test.js +67 -0
- package/src/rules/no-system-props.js +1 -0
- package/src/rules/no-unnecessary-components.js +160 -0
- package/src/rules/prefer-action-list-item-onselect.js +67 -0
- package/src/utils/is-primer-component.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-primer-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0-rc.2964cbe",
|
|
4
4
|
"description": "ESLint rules for Primer React",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
34
34
|
"eslint-traverse": "^1.0.0",
|
|
35
35
|
"lodash": "^4.17.21",
|
|
36
|
-
"styled-system": "^5.1.5"
|
|
36
|
+
"styled-system": "^5.1.5",
|
|
37
|
+
"@typescript-eslint/utils": "7.16.0",
|
|
38
|
+
"typescript": "^5.5.3"
|
|
37
39
|
},
|
|
38
40
|
"devDependencies": {
|
|
39
41
|
"@changesets/changelog-github": "^0.5.0",
|
|
@@ -44,7 +46,9 @@
|
|
|
44
46
|
"eslint-plugin-prettier": "^5.0.1",
|
|
45
47
|
"jest": "^29.7.0",
|
|
46
48
|
"markdownlint-cli2": "^0.13.0",
|
|
47
|
-
"markdownlint-cli2-formatter-pretty": "^0.0.6"
|
|
49
|
+
"markdownlint-cli2-formatter-pretty": "^0.0.6",
|
|
50
|
+
"@typescript-eslint/rule-tester": "7.16.0",
|
|
51
|
+
"@types/jest": "^29.5.12"
|
|
48
52
|
},
|
|
49
53
|
"prettier": "@github/prettier-config"
|
|
50
54
|
}
|
|
@@ -18,6 +18,8 @@ module.exports = {
|
|
|
18
18
|
'primer-react/no-deprecated-props': 'warn',
|
|
19
19
|
'primer-react/a11y-remove-disable-tooltip': 'error',
|
|
20
20
|
'primer-react/a11y-use-next-tooltip': 'error',
|
|
21
|
+
'primer-react/no-unnecessary-components': 'error',
|
|
22
|
+
'primer-react/prefer-action-list-item-onselect': 'error',
|
|
21
23
|
},
|
|
22
24
|
settings: {
|
|
23
25
|
github: {
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,8 @@ module.exports = {
|
|
|
11
11
|
'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'),
|
|
12
12
|
'a11y-use-next-tooltip': require('./rules/a11y-use-next-tooltip'),
|
|
13
13
|
'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'),
|
|
14
|
+
'primer-react/no-unnecessary-components': require('./rules/no-unnecessary-components'),
|
|
15
|
+
'primer-react/prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'),
|
|
14
16
|
},
|
|
15
17
|
configs: {
|
|
16
18
|
recommended: require('./configs/recommended'),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const {RuleTester} = require('@typescript-eslint/rule-tester')
|
|
4
|
+
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
const rule = require('../no-unnecessary-components')
|
|
7
|
+
const {components} = require('../no-unnecessary-components')
|
|
8
|
+
|
|
9
|
+
const prcImport = 'import React from "react"; import {Box, Text} from "@primer/react";'
|
|
10
|
+
const brandImport = 'import React from "react"; import {Box, Text} from "@primer/brand";'
|
|
11
|
+
|
|
12
|
+
/** @param {string} content */
|
|
13
|
+
const jsx = content => `export const Component = () => <>${content}</>`
|
|
14
|
+
|
|
15
|
+
const sxObjectDeclaration = `const props = {sx: {color: "red"}};`
|
|
16
|
+
const asObjectDeclaration = `const props = {as: "table"};`
|
|
17
|
+
const stringRecordDeclaration = `const props: Record<string, any> = {};`
|
|
18
|
+
const testIdObjectDeclaration = `const props = {'data-testid': 'xyz'};`
|
|
19
|
+
const componentDeclaration = `const OtherComponent = ({children}: {children: React.ReactNode}) => <>{children}</>;`
|
|
20
|
+
const asConstDeclaration = `const as = "p";`
|
|
21
|
+
|
|
22
|
+
const ruleTester = new RuleTester({
|
|
23
|
+
parser: '@typescript-eslint/parser',
|
|
24
|
+
parserOptions: {
|
|
25
|
+
tsconfigRootDir: path.resolve(__dirname, 'fixtures'),
|
|
26
|
+
project: path.resolve(__dirname, 'fixtures', 'tsconfig.json'),
|
|
27
|
+
},
|
|
28
|
+
defaultFilenames: {
|
|
29
|
+
ts: 'file.ts',
|
|
30
|
+
tsx: 'File.tsx',
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
jest.retryTimes(0, {logErrorsBeforeRetry: true})
|
|
35
|
+
|
|
36
|
+
const filename = 'File.tsx'
|
|
37
|
+
|
|
38
|
+
ruleTester.run('unnecessary-components', rule, {
|
|
39
|
+
valid: [
|
|
40
|
+
{name: 'Unrelated JSX', code: jsx('<span>Hello World</span>'), filename},
|
|
41
|
+
...Object.keys(components).flatMap(component => [
|
|
42
|
+
{
|
|
43
|
+
name: `Non-PRC ${component}`,
|
|
44
|
+
code: `${brandImport}${jsx(`<${component}>Hello World</${component}>`)}`,
|
|
45
|
+
filename,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: `${component} with sx prop`,
|
|
49
|
+
code: `${prcImport}${jsx(`<${component} sx={{color: "red"}}>Hello World</${component}>`)}`,
|
|
50
|
+
filename,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: `${component} with any styled-system prop`,
|
|
54
|
+
code: `${prcImport}${jsx(`<${component} flex="row">Hello World</${component}>`)}`,
|
|
55
|
+
filename,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: `${component} with spread sx prop`,
|
|
59
|
+
code: `${prcImport}${sxObjectDeclaration}${jsx(`<${component} {...props}>Hello World</${component}>`)}`,
|
|
60
|
+
filename,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: `${component} with string index spread props`,
|
|
64
|
+
code: `${prcImport}${stringRecordDeclaration}${jsx(`<${component} {...props}>Hello World</${component}>`)}`,
|
|
65
|
+
filename,
|
|
66
|
+
},
|
|
67
|
+
]),
|
|
68
|
+
{
|
|
69
|
+
name: `Text with weight prop`,
|
|
70
|
+
code: `${prcImport}${jsx(`<Text weight='medium'>Hello World</Text>`)}`,
|
|
71
|
+
filename,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: `Text with size prop`,
|
|
75
|
+
code: `${prcImport}${jsx(`<Text size='small'>Hello World</Text>`)}`,
|
|
76
|
+
filename,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
invalid: Object.entries(components).flatMap(([component, {messageId, replacement}]) => [
|
|
80
|
+
{
|
|
81
|
+
name: `${component} without any styled-system props`,
|
|
82
|
+
code: `${prcImport}${jsx(`<${component}>Hello World</${component}>`)}`,
|
|
83
|
+
output: `${prcImport}${jsx(`<${replacement}>Hello World</${replacement}>`)}`,
|
|
84
|
+
errors: [{messageId}],
|
|
85
|
+
filename,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: `Self-closing ${component} without any styled-system props`,
|
|
89
|
+
code: `${prcImport}${jsx(`<${component} />`)}`,
|
|
90
|
+
output: `${prcImport}${jsx(`<${replacement} />`)}`,
|
|
91
|
+
errors: [{messageId}],
|
|
92
|
+
filename,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: `${component} with spread props without sx`,
|
|
96
|
+
code: `${prcImport}${testIdObjectDeclaration}${jsx(`<${component} {...props}>Hello World</${component}>`)}`,
|
|
97
|
+
output: `${prcImport}${testIdObjectDeclaration}${jsx(`<${replacement} {...props}>Hello World</${replacement}>`)}`,
|
|
98
|
+
errors: [{messageId}],
|
|
99
|
+
filename,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: `${component} with string element 'as' prop`,
|
|
103
|
+
code: `${prcImport}${jsx(`<${component} as="code">Hello world</${component}>`)}`,
|
|
104
|
+
// There is extra whitespace here we don't worry about since formatters would get rid of it
|
|
105
|
+
output: `${prcImport}${jsx(`<code >Hello world</code>`)}`,
|
|
106
|
+
errors: [{messageId}],
|
|
107
|
+
filename,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: `${component} with single-character 'as' prop`,
|
|
111
|
+
code: `${prcImport}${jsx(`<${component} as="p">Hello world</${component}>`)}`,
|
|
112
|
+
output: `${prcImport}${jsx(`<p >Hello world</p>`)}`,
|
|
113
|
+
errors: [{messageId}],
|
|
114
|
+
filename,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: `${component} with string element 'as' prop surrounded by unnecessary braces`,
|
|
118
|
+
code: `${prcImport}${jsx(`<${component} as={"code"}>Hello world</${component}>`)}`,
|
|
119
|
+
output: `${prcImport}${jsx(`<code >Hello world</code>`)}`,
|
|
120
|
+
errors: [{messageId}],
|
|
121
|
+
filename,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: `${component} with component reference 'as' prop`,
|
|
125
|
+
code: `${prcImport}${componentDeclaration}${jsx(`<${component} as={OtherComponent}>Hello world</${component}>`)}`,
|
|
126
|
+
output: `${prcImport}${componentDeclaration}${jsx(`<OtherComponent >Hello world</OtherComponent>`)}`,
|
|
127
|
+
errors: [{messageId}],
|
|
128
|
+
filename,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: `${component} with spread 'as' prop`,
|
|
132
|
+
code: `${prcImport}${asObjectDeclaration}${jsx(`<${component} {...props}>Hello world</${component}>`)}`,
|
|
133
|
+
output: null,
|
|
134
|
+
errors: [{messageId}],
|
|
135
|
+
filename,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: `${component} with unusable lowercase reference 'as' prop`,
|
|
139
|
+
code: `${prcImport}${asConstDeclaration}${jsx(`<${component} as={as}>Hello world</${component}>`)}`,
|
|
140
|
+
output: null,
|
|
141
|
+
errors: [{messageId}],
|
|
142
|
+
filename,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: `Non-PRC ${component} when \`skipImportCheck\` is enabled`,
|
|
146
|
+
code: `${brandImport}${jsx(`<${component}>Hello World</${component}>`)}`,
|
|
147
|
+
output: `${brandImport}${jsx(`<${replacement}>Hello World</${replacement}>`)}`,
|
|
148
|
+
filename,
|
|
149
|
+
errors: [{messageId}],
|
|
150
|
+
options: [{skipImportCheck: true}],
|
|
151
|
+
},
|
|
152
|
+
]),
|
|
153
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const {RuleTester} = require('@typescript-eslint/rule-tester')
|
|
2
|
+
const rule = require('../prefer-action-list-item-onselect')
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester({
|
|
5
|
+
parser: require.resolve('@typescript-eslint/parser'),
|
|
6
|
+
parserOptions: {
|
|
7
|
+
ecmaVersion: 2018,
|
|
8
|
+
sourceType: 'module',
|
|
9
|
+
ecmaFeatures: {
|
|
10
|
+
jsx: true,
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
ruleTester.run('prefer-action-list-item-onselect', rule, {
|
|
16
|
+
valid: [
|
|
17
|
+
{code: `<ActionList.Item onSelect={() => console.log(1)} />`},
|
|
18
|
+
{code: `<ActionList.Item onSelect={() => console.log(1)} onClick={() => console.log(1)} />`},
|
|
19
|
+
{code: `<Other onClick={() => console.log(1)} />`},
|
|
20
|
+
{code: `<Button onClick={onSelect} />`},
|
|
21
|
+
{code: `<Button onSelect={onClick} />`},
|
|
22
|
+
{code: `<ActionList.Item onClick={() => console.log(1)} onKeyDown={() => console.log(1)} />`},
|
|
23
|
+
{code: `<ActionList.Item onClick={() => console.log(1)} onKeyUp={() => console.log(1)} />`},
|
|
24
|
+
// For now, we are not handling spread attributes as this is much less common
|
|
25
|
+
{code: `<ActionList.Item {...onClick} />`},
|
|
26
|
+
],
|
|
27
|
+
invalid: [
|
|
28
|
+
{
|
|
29
|
+
code: `<ActionList.Item onClick={() => console.log(1)} />`,
|
|
30
|
+
errors: [{messageId: 'prefer-action-list-item-onselect'}],
|
|
31
|
+
output: `<ActionList.Item onSelect={() => console.log(1)} />`,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
code: `<ActionList.Item aria-label="Edit item 1" onClick={() => console.log(1)} />`,
|
|
35
|
+
errors: [{messageId: 'prefer-action-list-item-onselect'}],
|
|
36
|
+
output: `<ActionList.Item aria-label="Edit item 1" onSelect={() => console.log(1)} />`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
code: `<ActionList.Item aria-label="Edit item 1" onClick={onClick} />`,
|
|
40
|
+
errors: [{messageId: 'prefer-action-list-item-onselect'}],
|
|
41
|
+
output: `<ActionList.Item aria-label="Edit item 1" onSelect={onClick} />`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
code: `<ActionList.Item
|
|
45
|
+
aria-label="Edit item 1"
|
|
46
|
+
onClick={(event) => {
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
handleClick()
|
|
49
|
+
}}
|
|
50
|
+
/>`,
|
|
51
|
+
errors: [{messageId: 'prefer-action-list-item-onselect'}],
|
|
52
|
+
output: `<ActionList.Item
|
|
53
|
+
aria-label="Edit item 1"
|
|
54
|
+
onSelect={(event) => {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
handleClick()
|
|
57
|
+
}}
|
|
58
|
+
/>`,
|
|
59
|
+
},
|
|
60
|
+
// This is invalid, but we can fix it anyway
|
|
61
|
+
{
|
|
62
|
+
code: `<ActionList.Item aria-label="Edit item 1" onClick />`,
|
|
63
|
+
errors: [{messageId: 'prefer-action-list-item-onselect'}],
|
|
64
|
+
output: `<ActionList.Item aria-label="Edit item 1" onSelect />`,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
})
|
|
@@ -46,6 +46,7 @@ const excludedComponentProps = new Map([
|
|
|
46
46
|
['ProgressBar', new Set(['bg'])],
|
|
47
47
|
['PointerBox', new Set(['bg'])],
|
|
48
48
|
['Truncate', new Set(['maxWidth'])],
|
|
49
|
+
['Stack', new Set(['padding', 'gap'])],
|
|
49
50
|
])
|
|
50
51
|
|
|
51
52
|
const alwaysExcludedProps = new Set(['variant', 'size'])
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const {ESLintUtils} = require('@typescript-eslint/utils')
|
|
4
|
+
const {IndexKind} = require('typescript')
|
|
5
|
+
const {pick: pickStyledSystemProps} = require('@styled-system/props')
|
|
6
|
+
const {isPrimerComponent} = require('../utils/is-primer-component')
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('@typescript-eslint/types').TSESTree.JSXAttribute} JSXAttribute */
|
|
9
|
+
|
|
10
|
+
const components = {
|
|
11
|
+
Box: {
|
|
12
|
+
replacement: 'div',
|
|
13
|
+
messageId: 'unecessaryBox',
|
|
14
|
+
message: 'Prefer plain HTML elements over `Box` when not using `sx` for styling.',
|
|
15
|
+
allowedProps: new Set(['sx']), // + styled-system props
|
|
16
|
+
},
|
|
17
|
+
Text: {
|
|
18
|
+
replacement: 'span',
|
|
19
|
+
messageId: 'unecessarySpan',
|
|
20
|
+
message: 'Prefer plain HTML elements over `Text` when not using `sx` for styling.',
|
|
21
|
+
allowedProps: new Set(['sx', 'size', 'weight']), // + styled-system props
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const elementNameRegex = /^[a-z]\w*$/
|
|
26
|
+
const componentNameRegex = /^[A-Z][\w._]*$/
|
|
27
|
+
|
|
28
|
+
/** @param {string} propName */
|
|
29
|
+
const isStyledSystemProp = propName => propName in pickStyledSystemProps({[propName]: propName})
|
|
30
|
+
|
|
31
|
+
const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|
32
|
+
meta: {
|
|
33
|
+
docs: {
|
|
34
|
+
description:
|
|
35
|
+
'`Box` and `Text` should only be used to provide access to the `sx` styling system and have a performance cost. If `sx` props are not being used, prefer `div` and `span` instead.',
|
|
36
|
+
},
|
|
37
|
+
messages: {
|
|
38
|
+
[components.Box.messageId]: components.Box.message,
|
|
39
|
+
[components.Text.messageId]: components.Text.message,
|
|
40
|
+
},
|
|
41
|
+
type: 'problem',
|
|
42
|
+
schema: [
|
|
43
|
+
{
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
skipImportCheck: {
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
fixable: 'code',
|
|
54
|
+
},
|
|
55
|
+
defaultOptions: [{skipImportCheck: false}],
|
|
56
|
+
create(context) {
|
|
57
|
+
return {
|
|
58
|
+
JSXElement({openingElement, closingElement}) {
|
|
59
|
+
const {name, attributes} = openingElement
|
|
60
|
+
|
|
61
|
+
// Ensure this is one of the components we are looking for. Note this doesn't account for import aliases; this
|
|
62
|
+
// is intentional to avoid having to do the scope tree traversal for every component of every name, which would
|
|
63
|
+
// be needlessly expensive. We just ignore aliased imports.
|
|
64
|
+
if (name.type !== 'JSXIdentifier' || !(name.name in components)) return
|
|
65
|
+
const componentConfig = components[/** @type {keyof typeof components} */ (name.name)]
|
|
66
|
+
|
|
67
|
+
// Only continue if the variable declaration is an import from @primer/react. Otherwise it could, for example,
|
|
68
|
+
// be an import from @primer/brand, which would be valid without sx.
|
|
69
|
+
const skipImportCheck = context.options[0]?.skipImportCheck
|
|
70
|
+
const isPrimer = skipImportCheck || isPrimerComponent(name, context.sourceCode.getScope(openingElement))
|
|
71
|
+
if (!isPrimer) return
|
|
72
|
+
|
|
73
|
+
/** @param {string} name */
|
|
74
|
+
const isAllowedProp = name => componentConfig.allowedProps.has(name) || isStyledSystemProp(name)
|
|
75
|
+
|
|
76
|
+
// Validate the attributes and ensure an allowed prop is present or spreaded in
|
|
77
|
+
/** @type {typeof attributes[number] | undefined | null} */
|
|
78
|
+
let asProp = undefined
|
|
79
|
+
for (const attribute of attributes) {
|
|
80
|
+
// If there is a spread type, check if the type of the spreaded value has an allowed property
|
|
81
|
+
if (attribute.type === 'JSXSpreadAttribute') {
|
|
82
|
+
const services = ESLintUtils.getParserServices(context)
|
|
83
|
+
const typeChecker = services.program.getTypeChecker()
|
|
84
|
+
|
|
85
|
+
const spreadType = services.getTypeAtLocation(attribute.argument)
|
|
86
|
+
|
|
87
|
+
// Check if the spread type has a string index signature - this could hide an allowed property
|
|
88
|
+
if (typeChecker.getIndexTypeOfType(spreadType, IndexKind.String) !== undefined) return
|
|
89
|
+
|
|
90
|
+
const spreadPropNames = typeChecker.getPropertiesOfType(spreadType).map(prop => prop.getName())
|
|
91
|
+
|
|
92
|
+
// If an allowed prop gets spread in, this is a valid use of the component
|
|
93
|
+
if (spreadPropNames.some(isAllowedProp)) return
|
|
94
|
+
|
|
95
|
+
// If there is an `as` inside the spread object, we can't autofix reliably
|
|
96
|
+
if (spreadPropNames.includes('as')) asProp = null
|
|
97
|
+
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Has an allowed prop, so should keep using this component
|
|
102
|
+
if (attribute.name.type === 'JSXIdentifier' && isAllowedProp(attribute.name.name)) return
|
|
103
|
+
|
|
104
|
+
// If there is an `as` prop we will need to account for that when autofixing
|
|
105
|
+
if (attribute.name.type === 'JSXIdentifier' && attribute.name.name === 'as') asProp = attribute
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Determine a replacement component name accounting for the `as` prop if present
|
|
109
|
+
/** @type {string | null} */
|
|
110
|
+
let replacement = componentConfig.replacement
|
|
111
|
+
if (asProp === null) {
|
|
112
|
+
// {...{as: 'something-unusable'}}
|
|
113
|
+
replacement = null
|
|
114
|
+
} else if (asProp?.type === 'JSXAttribute') {
|
|
115
|
+
// as={ComponentReference}
|
|
116
|
+
if (asProp.value?.type === 'JSXExpressionContainer' && asProp.value.expression.type === 'Identifier') {
|
|
117
|
+
// can't just use expression.name here because we want the whole expression if it's A.B
|
|
118
|
+
const expressionStr = context.sourceCode.getText(asProp.value.expression)
|
|
119
|
+
replacement = componentNameRegex.test(expressionStr) ? expressionStr : null
|
|
120
|
+
}
|
|
121
|
+
// as={'tagName'} (surprisingly common, we really should enable `react/jsx-curly-brace-presence`)
|
|
122
|
+
else if (
|
|
123
|
+
asProp.value?.type === 'JSXExpressionContainer' &&
|
|
124
|
+
asProp.value.expression.type === 'Literal' &&
|
|
125
|
+
typeof asProp.value.expression.value === 'string' &&
|
|
126
|
+
elementNameRegex.test(asProp.value.expression.value)
|
|
127
|
+
) {
|
|
128
|
+
replacement = asProp.value.expression.value
|
|
129
|
+
}
|
|
130
|
+
// as="tagName"
|
|
131
|
+
else if (
|
|
132
|
+
asProp.value?.type === 'Literal' &&
|
|
133
|
+
typeof asProp.value.value === 'string' &&
|
|
134
|
+
elementNameRegex.test(asProp.value.value)
|
|
135
|
+
) {
|
|
136
|
+
replacement = asProp.value.value
|
|
137
|
+
}
|
|
138
|
+
// too complex to autofix
|
|
139
|
+
else {
|
|
140
|
+
replacement = null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
context.report({
|
|
145
|
+
node: name,
|
|
146
|
+
messageId: componentConfig.messageId,
|
|
147
|
+
fix: replacement
|
|
148
|
+
? function* (fixer) {
|
|
149
|
+
yield fixer.replaceText(name, replacement)
|
|
150
|
+
if (closingElement) yield fixer.replaceText(closingElement.name, replacement)
|
|
151
|
+
if (asProp) yield fixer.remove(asProp)
|
|
152
|
+
}
|
|
153
|
+
: undefined,
|
|
154
|
+
})
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
module.exports = {...rule, components}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const messages = {
|
|
4
|
+
'prefer-action-list-item-onselect': `Use the 'onSelect' event handler instead of 'onClick' for ActionList.Item components, so that it is accessible by keyboard and mouse.`,
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** @type {import('@typescript-eslint/utils/ts-eslint').RuleModule<keyof typeof messages>} */
|
|
8
|
+
module.exports = {
|
|
9
|
+
meta: {
|
|
10
|
+
docs: {
|
|
11
|
+
description:
|
|
12
|
+
'To do something when an `ActionList.Item` is selected, you should use the `onSelect` event handler instead of `onClick`, because it handles both keyboard and mouse events. Otherwise, it will only be accessible by mouse.',
|
|
13
|
+
recommended: true,
|
|
14
|
+
},
|
|
15
|
+
messages,
|
|
16
|
+
type: 'problem',
|
|
17
|
+
schema: [],
|
|
18
|
+
fixable: 'code',
|
|
19
|
+
},
|
|
20
|
+
defaultOptions: [],
|
|
21
|
+
create(context) {
|
|
22
|
+
return {
|
|
23
|
+
JSXElement(node) {
|
|
24
|
+
// Only check components that have the name `ActionList.Item`. We don't check if this comes from Primer
|
|
25
|
+
// because the chance of conflict here is very low
|
|
26
|
+
const isActionListItem =
|
|
27
|
+
node.openingElement.name.type === 'JSXMemberExpression' &&
|
|
28
|
+
node.openingElement.name.object.type === 'JSXIdentifier' &&
|
|
29
|
+
node.openingElement.name.object.name === 'ActionList' &&
|
|
30
|
+
node.openingElement.name.property.name === 'Item'
|
|
31
|
+
if (!isActionListItem) return
|
|
32
|
+
|
|
33
|
+
const attributes = node.openingElement.attributes
|
|
34
|
+
const onClickAttribute = attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'onClick')
|
|
35
|
+
const onSelectAttribute = attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'onSelect')
|
|
36
|
+
|
|
37
|
+
const keyboardHandlers = ['onKeyDown', 'onKeyUp']
|
|
38
|
+
const keyboardAttributes = attributes.filter(
|
|
39
|
+
attr =>
|
|
40
|
+
attr.type === 'JSXAttribute' &&
|
|
41
|
+
(typeof attr.name.name === 'string'
|
|
42
|
+
? keyboardHandlers.includes(attr.name.name)
|
|
43
|
+
: keyboardHandlers.includes(attr.name.name.name)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// If the component has `onSelect`, then it's already using the correct event
|
|
47
|
+
if (onSelectAttribute) return
|
|
48
|
+
// If there is no `onClick` attribute, then we should also be fine
|
|
49
|
+
if (!onClickAttribute) return
|
|
50
|
+
// If there is an onClick attribute as well as keyboard handlers, we will assume it is handled correctly
|
|
51
|
+
if (onClickAttribute && keyboardAttributes.length > 0) return
|
|
52
|
+
|
|
53
|
+
context.report({
|
|
54
|
+
node: onClickAttribute,
|
|
55
|
+
messageId: 'prefer-action-list-item-onselect',
|
|
56
|
+
fix: fixer => {
|
|
57
|
+
// Replace `onClick` with `onSelect`
|
|
58
|
+
if (onClickAttribute.type === 'JSXAttribute') {
|
|
59
|
+
return fixer.replaceText(onClickAttribute.name, 'onSelect')
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|