eslint-plugin-primer-react 3.0.0 → 4.0.0-rc.2ea2bf7
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/.github/dependabot.yml +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/docs/rules/a11y-explicit-heading.md +37 -0
- package/docs/rules/a11y-tooltip-non-interactive-trigger.md +41 -0
- package/package-lock.json +15480 -0
- package/package.json +4 -4
- package/src/configs/components.js +26 -0
- package/src/configs/recommended.js +7 -9
- package/src/index.js +3 -1
- package/src/rules/__tests__/a11y-explicit-heading.test.js +54 -0
- package/src/rules/__tests__/a11y-tooltip-interactive-trigger.test.js +167 -0
- package/src/rules/a11y-explicit-heading.js +66 -0
- package/src/rules/a11y-tooltip-interactive-trigger.js +179 -0
- package/src/url.js +10 -0
- package/src/utils/__tests__/flatten-components.test.js +62 -0
- package/src/utils/flatten-components.js +17 -0
- package/src/utils/get-jsx-opening-element-attribute.js +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-primer-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-rc.2ea2bf7",
|
|
4
4
|
"description": "ESLint rules for Primer React",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@changesets/changelog-github": "^0.4.0",
|
|
25
25
|
"@changesets/cli": "^2.16.0",
|
|
26
26
|
"@github/prettier-config": "0.0.4",
|
|
27
|
-
"@primer/primitives": "^
|
|
27
|
+
"@primer/primitives": "^7.11.14",
|
|
28
28
|
"eslint": "^8.0.1",
|
|
29
29
|
"jest": "^27.0.6"
|
|
30
30
|
},
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"prettier": "@github/prettier-config",
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@styled-system/props": "^5.1.5",
|
|
38
|
-
"eslint-plugin-github": "^4.
|
|
39
|
-
"eslint-plugin-jsx-a11y": "^6.
|
|
38
|
+
"eslint-plugin-github": "^4.9.2",
|
|
39
|
+
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
40
40
|
"eslint-traverse": "^1.0.0",
|
|
41
41
|
"lodash": "^4.17.21",
|
|
42
42
|
"styled-system": "^5.1.5"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const { flattenComponents } = require('../utils/flatten-components');
|
|
2
|
+
|
|
3
|
+
const components = flattenComponents({
|
|
4
|
+
Button: 'button',
|
|
5
|
+
IconButton: 'button',
|
|
6
|
+
Link: 'a',
|
|
7
|
+
Spinner: 'svg',
|
|
8
|
+
ToggleSwitch: 'button',
|
|
9
|
+
Radio: 'input',
|
|
10
|
+
Checkbox: 'input',
|
|
11
|
+
Text: 'span',
|
|
12
|
+
TextInput: {
|
|
13
|
+
Action: 'button',
|
|
14
|
+
self: 'input',
|
|
15
|
+
},
|
|
16
|
+
Select: {
|
|
17
|
+
Option: 'option',
|
|
18
|
+
self: 'select',
|
|
19
|
+
},
|
|
20
|
+
TabNav: {
|
|
21
|
+
Link: 'a',
|
|
22
|
+
self: 'nav',
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
module.exports = components;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const components = require('./components');
|
|
2
|
+
|
|
1
3
|
module.exports = {
|
|
2
4
|
parserOptions: {
|
|
3
5
|
sourceType: 'module',
|
|
@@ -10,20 +12,16 @@ module.exports = {
|
|
|
10
12
|
rules: {
|
|
11
13
|
'primer-react/direct-slot-children': 'error',
|
|
12
14
|
'primer-react/no-deprecated-colors': 'warn',
|
|
13
|
-
'primer-react/no-system-props': 'warn'
|
|
15
|
+
'primer-react/no-system-props': 'warn',
|
|
16
|
+
'primer-react/a11y-tooltip-interactive-trigger': 'error',
|
|
17
|
+
'primer-react/a11y-explicit-heading': 'error'
|
|
14
18
|
},
|
|
15
19
|
settings: {
|
|
16
20
|
github: {
|
|
17
|
-
components:
|
|
18
|
-
Link: {props: {as: {undefined: 'a', a: 'a', button: 'button'}}},
|
|
19
|
-
Button: {default: 'button'}
|
|
20
|
-
}
|
|
21
|
+
components: components
|
|
21
22
|
},
|
|
22
23
|
'jsx-a11y': {
|
|
23
|
-
components:
|
|
24
|
-
Button: 'button',
|
|
25
|
-
IconButton: 'button'
|
|
26
|
-
}
|
|
24
|
+
components: components
|
|
27
25
|
}
|
|
28
26
|
}
|
|
29
27
|
}
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,9 @@ module.exports = {
|
|
|
2
2
|
rules: {
|
|
3
3
|
'direct-slot-children': require('./rules/direct-slot-children'),
|
|
4
4
|
'no-deprecated-colors': require('./rules/no-deprecated-colors'),
|
|
5
|
-
'no-system-props': require('./rules/no-system-props')
|
|
5
|
+
'no-system-props': require('./rules/no-system-props'),
|
|
6
|
+
'a11y-tooltip-interactive-trigger': require('./rules/a11y-tooltip-interactive-trigger'),
|
|
7
|
+
'a11y-explicit-heading': require('./rules/a11y-explicit-heading')
|
|
6
8
|
},
|
|
7
9
|
configs: {
|
|
8
10
|
recommended: require('./configs/recommended')
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const rule = require('../a11y-explicit-heading')
|
|
2
|
+
const {RuleTester} = require('eslint')
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester({
|
|
5
|
+
parserOptions: {
|
|
6
|
+
ecmaVersion: 'latest',
|
|
7
|
+
sourceType: 'module',
|
|
8
|
+
ecmaFeatures: {
|
|
9
|
+
jsx: true
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
ruleTester.run('a11y-explicit-heading', rule, {
|
|
15
|
+
valid: [
|
|
16
|
+
`import {Heading} from '@primer/react';
|
|
17
|
+
<Heading as="h1">Heading level 1</Heading>
|
|
18
|
+
`,
|
|
19
|
+
`import {Heading} from '@primer/react';
|
|
20
|
+
<Heading as="h2">Heading level 2</Heading>
|
|
21
|
+
`,
|
|
22
|
+
`import {Heading} from '@primer/react';
|
|
23
|
+
<Heading as="H3">Heading level 3</Heading>
|
|
24
|
+
`,
|
|
25
|
+
`import {Heading} from '@primer/react';
|
|
26
|
+
const args = {};
|
|
27
|
+
<Heading {...args}>A heading with spread props</Heading>
|
|
28
|
+
`,
|
|
29
|
+
`import {Heading} from '@primer/react';
|
|
30
|
+
const as = 'h3';
|
|
31
|
+
<Heading as={as}>Heading as passed prop</Heading>
|
|
32
|
+
`,
|
|
33
|
+
`import {Heading} from '@primer/react';
|
|
34
|
+
const args = {};
|
|
35
|
+
<Heading as="h2" {...args}>Heading level 2</Heading>
|
|
36
|
+
`,
|
|
37
|
+
|
|
38
|
+
],
|
|
39
|
+
invalid: [
|
|
40
|
+
{
|
|
41
|
+
code:
|
|
42
|
+
`import {Heading} from '@primer/react';
|
|
43
|
+
<Heading>Heading without "as"</Heading>`,
|
|
44
|
+
errors: [{ messageId: 'nonExplicitHeadingLevel' }]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
code:
|
|
48
|
+
`import {Heading} from '@primer/react';
|
|
49
|
+
<Heading as="span">Heading component used as "span"</Heading>
|
|
50
|
+
`,
|
|
51
|
+
errors: [{ messageId: 'invalidAsValue' }]
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
const rule = require('../a11y-tooltip-interactive-trigger')
|
|
2
|
+
const {RuleTester} = require('eslint')
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester({
|
|
5
|
+
parserOptions: {
|
|
6
|
+
ecmaVersion: 'latest',
|
|
7
|
+
sourceType: 'module',
|
|
8
|
+
ecmaFeatures: {
|
|
9
|
+
jsx: true
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
ruleTester.run('non-interactive-tooltip-trigger', rule, {
|
|
15
|
+
valid: [
|
|
16
|
+
`import {Tooltip, Button} from '@primer/react';
|
|
17
|
+
<Tooltip aria-label="Filter vegetarian options" direction="e">
|
|
18
|
+
<Button>🥦</Button>
|
|
19
|
+
</Tooltip>`,
|
|
20
|
+
|
|
21
|
+
`import {Tooltip, Button} from '@primer/react';
|
|
22
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
23
|
+
<Button>Save</Button>
|
|
24
|
+
</Tooltip>`,
|
|
25
|
+
|
|
26
|
+
`import {Tooltip, IconButton} from '@primer/react';
|
|
27
|
+
import {SearchIcon} from '@primer/octicons-react';
|
|
28
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
29
|
+
<IconButton icon={SearchIcon} aria-label="Search" />
|
|
30
|
+
</Tooltip>`,
|
|
31
|
+
|
|
32
|
+
`import {Tooltip, Button} from '@primer/react';
|
|
33
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
34
|
+
<div>
|
|
35
|
+
<Button>Save</Button>
|
|
36
|
+
</div>
|
|
37
|
+
</Tooltip>`,
|
|
38
|
+
|
|
39
|
+
`import {Tooltip, Button} from '@primer/react';
|
|
40
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
41
|
+
<div>
|
|
42
|
+
<a href="https://gthub.com">Save</a>
|
|
43
|
+
</div>
|
|
44
|
+
</Tooltip>`,
|
|
45
|
+
|
|
46
|
+
`import {Tooltip} from '@primer/react';
|
|
47
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
48
|
+
<a href="https://github.com">see commit message</a>
|
|
49
|
+
</Tooltip>`,
|
|
50
|
+
|
|
51
|
+
`import {Tooltip, Link} from '@primer/react';
|
|
52
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
53
|
+
<Link href="https://github.com">Link</Link>
|
|
54
|
+
</Tooltip>`
|
|
55
|
+
],
|
|
56
|
+
invalid: [
|
|
57
|
+
{
|
|
58
|
+
code: `import {Tooltip} from '@primer/react';<Tooltip type="description" text="supportive text" direction="e"><button>button1</button><button>button2</button></Tooltip>
|
|
59
|
+
`,
|
|
60
|
+
errors: [
|
|
61
|
+
{
|
|
62
|
+
messageId: 'singleChild'
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
code: `
|
|
68
|
+
import {Tooltip} from '@primer/react';
|
|
69
|
+
<Tooltip aria-label="Filter vegetarian options" direction="e">
|
|
70
|
+
<span>non interactive element</span>
|
|
71
|
+
</Tooltip>
|
|
72
|
+
`,
|
|
73
|
+
errors: [
|
|
74
|
+
{
|
|
75
|
+
messageId: 'nonInteractiveTrigger'
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
code: `
|
|
81
|
+
import {Tooltip, Button} from '@primer/react';
|
|
82
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
83
|
+
<h1>Save</h1>
|
|
84
|
+
</Tooltip>`,
|
|
85
|
+
errors: [
|
|
86
|
+
{
|
|
87
|
+
messageId: 'nonInteractiveTrigger'
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
code: `
|
|
93
|
+
import {Tooltip} from '@primer/react';
|
|
94
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
95
|
+
<a>see commit message</a>
|
|
96
|
+
</Tooltip>`,
|
|
97
|
+
errors: [
|
|
98
|
+
{
|
|
99
|
+
messageId: 'anchorTagWithoutHref'
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
code: `
|
|
105
|
+
import {Tooltip, Link} from '@primer/react';
|
|
106
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
107
|
+
<Link>see commit message</Link>
|
|
108
|
+
</Tooltip>`,
|
|
109
|
+
errors: [
|
|
110
|
+
{
|
|
111
|
+
messageId: 'anchorTagWithoutHref'
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
code: `
|
|
117
|
+
import {Tooltip} from '@primer/react';
|
|
118
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
119
|
+
<input type="hidden" />
|
|
120
|
+
</Tooltip>`,
|
|
121
|
+
errors: [
|
|
122
|
+
{
|
|
123
|
+
messageId: 'hiddenInput'
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
code: `
|
|
129
|
+
import {Tooltip, TextInput} from '@primer/react';
|
|
130
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
131
|
+
<TextInput type="hidden" aria-label="Zipcode" name="zipcode" placeholder="Zipcode" autoComplete="postal-code" />
|
|
132
|
+
</Tooltip>`,
|
|
133
|
+
errors: [
|
|
134
|
+
{
|
|
135
|
+
messageId: 'hiddenInput'
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
code: `
|
|
141
|
+
import {Tooltip, Button} from '@primer/react';
|
|
142
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
143
|
+
<header>
|
|
144
|
+
<span>Save</span>
|
|
145
|
+
</header>
|
|
146
|
+
</Tooltip>`,
|
|
147
|
+
errors: [
|
|
148
|
+
{
|
|
149
|
+
messageId: 'nonInteractiveTrigger'
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
code: `import {Tooltip, Button} from '@primer/react';
|
|
155
|
+
<Tooltip aria-label="Supplementary text" direction="e">
|
|
156
|
+
<h1>
|
|
157
|
+
<a>Save</a>
|
|
158
|
+
</h1>
|
|
159
|
+
</Tooltip>`,
|
|
160
|
+
errors: [
|
|
161
|
+
{
|
|
162
|
+
messageId: 'anchorTagWithoutHref'
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const {isPrimerComponent} = require('../utils/is-primer-component')
|
|
2
|
+
const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
|
|
3
|
+
const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
|
|
4
|
+
|
|
5
|
+
const validHeadings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
|
|
6
|
+
|
|
7
|
+
const isHeadingComponent = elem => getJSXOpeningElementName(elem) === 'Heading'
|
|
8
|
+
const isUsingAsProp = elem => {
|
|
9
|
+
const componentAs = getJSXOpeningElementAttribute(elem, 'as')
|
|
10
|
+
|
|
11
|
+
if (!componentAs) return
|
|
12
|
+
|
|
13
|
+
return componentAs.value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isValidAsUsage = value => validHeadings.includes(value.toLowerCase())
|
|
17
|
+
const isInvalid = elem => {
|
|
18
|
+
if (elem.attributes.length === 1 && elem.attributes[0].type === 'JSXSpreadAttribute') return;
|
|
19
|
+
|
|
20
|
+
const elemAs = isUsingAsProp(elem)
|
|
21
|
+
|
|
22
|
+
if (!elemAs) return 'nonExplicitHeadingLevel'
|
|
23
|
+
if (elemAs.value && !isValidAsUsage(elemAs.value)) return 'invalidAsValue'
|
|
24
|
+
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
meta: {
|
|
30
|
+
docs: {
|
|
31
|
+
description: 'Heading component must have explicit heading level, and specific `as` usage.',
|
|
32
|
+
url: require('../url')(module),
|
|
33
|
+
},
|
|
34
|
+
schema: [
|
|
35
|
+
{
|
|
36
|
+
properties: {
|
|
37
|
+
skipImportCheck: {
|
|
38
|
+
type: 'boolean'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
messages: {
|
|
44
|
+
nonExplicitHeadingLevel: 'Heading must have an explicit heading level applied through the `as` prop.',
|
|
45
|
+
invalidAsValue: 'Usage of `as` must only be used for heading elements (h1-h6).'
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
create: function(context) {
|
|
49
|
+
return {
|
|
50
|
+
JSXOpeningElement(jsxNode) {
|
|
51
|
+
const skipImportCheck = context.options[0] ? context.options[0].skipImportCheck : false
|
|
52
|
+
|
|
53
|
+
if ((skipImportCheck || isPrimerComponent(jsxNode.name, context.getScope(jsxNode))) && isHeadingComponent(jsxNode)) {
|
|
54
|
+
const error = isInvalid(jsxNode)
|
|
55
|
+
|
|
56
|
+
if (error) {
|
|
57
|
+
context.report({
|
|
58
|
+
node: jsxNode,
|
|
59
|
+
messageId: error
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const {isPrimerComponent} = require('../utils/is-primer-component')
|
|
2
|
+
const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
|
|
3
|
+
const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
|
|
4
|
+
|
|
5
|
+
const isInteractive = child => {
|
|
6
|
+
const childName = getJSXOpeningElementName(child.openingElement)
|
|
7
|
+
return ['button', 'summary', 'select', 'textarea', 'a', 'input', 'link', 'iconbutton', 'textinput'].includes(
|
|
8
|
+
childName.toLowerCase()
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const isAnchorTag = el => {
|
|
13
|
+
return (
|
|
14
|
+
getJSXOpeningElementName(el.openingElement) === 'a' ||
|
|
15
|
+
getJSXOpeningElementName(el.openingElement).toLowerCase() === 'link'
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isInteractiveAnchor = child => {
|
|
20
|
+
const hasHref = getJSXOpeningElementAttribute(child.openingElement, 'href')
|
|
21
|
+
if (!hasHref) return false
|
|
22
|
+
const href = getJSXOpeningElementAttribute(child.openingElement, 'href').value.value
|
|
23
|
+
const isAnchorInteractive = typeof href === 'string' && href !== ''
|
|
24
|
+
return isAnchorInteractive
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isInputTag = el => {
|
|
28
|
+
return (
|
|
29
|
+
getJSXOpeningElementName(el.openingElement) === 'input' ||
|
|
30
|
+
getJSXOpeningElementName(el.openingElement).toLowerCase() === 'textinput'
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isInteractiveInput = child => {
|
|
35
|
+
const hasHiddenType =
|
|
36
|
+
getJSXOpeningElementAttribute(child.openingElement, 'type') &&
|
|
37
|
+
getJSXOpeningElementAttribute(child.openingElement, 'type').value.value === 'hidden'
|
|
38
|
+
return !hasHiddenType
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isOtherThanAnchorOrInput = el => {
|
|
42
|
+
return !isAnchorTag(el) && !isInputTag(el)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const getAllChildren = node => {
|
|
46
|
+
if (Array.isArray(node.children)) {
|
|
47
|
+
return node.children
|
|
48
|
+
.filter(child => {
|
|
49
|
+
return child.type === 'JSXElement'
|
|
50
|
+
})
|
|
51
|
+
.flatMap(child => {
|
|
52
|
+
return [child, ...getAllChildren(child)]
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const checks = [
|
|
59
|
+
{
|
|
60
|
+
id: 'anchorTagWithoutHref',
|
|
61
|
+
filter: jsxElement => isAnchorTag(jsxElement),
|
|
62
|
+
check: isInteractiveAnchor
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'hiddenInput',
|
|
66
|
+
filter: jsxElement => isInputTag(jsxElement),
|
|
67
|
+
check: isInteractiveInput
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'nonInteractiveTrigger',
|
|
71
|
+
filter: jsxElement => isOtherThanAnchorOrInput(jsxElement),
|
|
72
|
+
check: isInteractive
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const checkTriggerElement = jsxNode => {
|
|
77
|
+
const elements = [...getAllChildren(jsxNode)]
|
|
78
|
+
const hasInteractiveElement = elements.find(element => {
|
|
79
|
+
if (
|
|
80
|
+
getJSXOpeningElementName(element.openingElement) === 'a' ||
|
|
81
|
+
getJSXOpeningElementName(element.openingElement) === 'Link'
|
|
82
|
+
) {
|
|
83
|
+
return isInteractiveAnchor(element)
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
getJSXOpeningElementName(element.openingElement) === 'input' ||
|
|
87
|
+
getJSXOpeningElementName(element.openingElement) === 'TextInput'
|
|
88
|
+
) {
|
|
89
|
+
return isInteractiveInput(element)
|
|
90
|
+
} else {
|
|
91
|
+
return isInteractive(element)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// If the tooltip has interactive elements, return.
|
|
96
|
+
if (hasInteractiveElement) return
|
|
97
|
+
|
|
98
|
+
const errors = new Set()
|
|
99
|
+
|
|
100
|
+
for (const element of elements) {
|
|
101
|
+
for (const check of checks) {
|
|
102
|
+
if (!check.filter(element)) {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!check.check(element)) {
|
|
107
|
+
errors.add(check.id)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// check the specificity of the errors. If there are multiple errors, only return the most specific one.
|
|
112
|
+
if (errors.size > 1) {
|
|
113
|
+
if (errors.has('anchorTagWithoutHref')) {
|
|
114
|
+
errors.delete('nonInteractiveTrigger')
|
|
115
|
+
}
|
|
116
|
+
if (errors.has('hiddenInput')) {
|
|
117
|
+
errors.delete('nonInteractiveTrigger')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return errors
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
meta: {
|
|
126
|
+
type: 'problem',
|
|
127
|
+
schema: [
|
|
128
|
+
{
|
|
129
|
+
properties: {
|
|
130
|
+
skipImportCheck: {
|
|
131
|
+
type: 'boolean'
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
],
|
|
136
|
+
messages: {
|
|
137
|
+
nonInteractiveTrigger:
|
|
138
|
+
'The `Tooltip` component expects a single React element that contains interactive content. Consider using a `<button>` or equivalent interactive element instead.',
|
|
139
|
+
anchorTagWithoutHref:
|
|
140
|
+
'Anchor tags without an href attribute are not interactive, therefore they cannot be used as a trigger for a tooltip. Please add an href attribute or use an alternative interactive element instead',
|
|
141
|
+
hiddenInput:
|
|
142
|
+
'Hidden inputs are not interactive and cannot be used as a trigger for a tooltip. Please use an alternate input type or use a different interactive element instead',
|
|
143
|
+
singleChild: 'The `Tooltip` component expects a single React element as a child.'
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
create(context) {
|
|
147
|
+
return {
|
|
148
|
+
JSXElement(jsxNode) {
|
|
149
|
+
// If `skipImportCheck` is true, this rule will check for non-interactive element in any components (not just ones that are imported from `@primer/react`).
|
|
150
|
+
const skipImportCheck = context.options[0] ? context.options[0].skipImportCheck : false
|
|
151
|
+
const name = getJSXOpeningElementName(jsxNode.openingElement)
|
|
152
|
+
if (
|
|
153
|
+
(skipImportCheck || isPrimerComponent(jsxNode.openingElement.name, context.getScope(jsxNode))) &&
|
|
154
|
+
name === 'Tooltip' &&
|
|
155
|
+
jsxNode.children
|
|
156
|
+
) {
|
|
157
|
+
if (jsxNode.children.filter(child => child.type === 'JSXElement').length > 1) {
|
|
158
|
+
context.report({
|
|
159
|
+
node: jsxNode,
|
|
160
|
+
messageId: 'singleChild'
|
|
161
|
+
})
|
|
162
|
+
} else {
|
|
163
|
+
// Check if the child is interactive
|
|
164
|
+
const errors = checkTriggerElement(jsxNode)
|
|
165
|
+
|
|
166
|
+
if (errors) {
|
|
167
|
+
for (const [key, value] of errors.entries()) {
|
|
168
|
+
context.report({
|
|
169
|
+
node: jsxNode,
|
|
170
|
+
messageId: value
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/url.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const {homepage, version} = require('../package.json')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
|
|
4
|
+
module.exports = ({id}) => {
|
|
5
|
+
const url = new URL(homepage)
|
|
6
|
+
const rule = path.basename(id, '.js')
|
|
7
|
+
url.hash = ''
|
|
8
|
+
url.pathname += `/blob/v${version}/docs/rules/${rule}.md`
|
|
9
|
+
return url.toString()
|
|
10
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const {flattenComponents} = require('../flatten-components')
|
|
2
|
+
|
|
3
|
+
const mockComponents = function(passedObj) {
|
|
4
|
+
return {
|
|
5
|
+
Button: 'button',
|
|
6
|
+
Link: 'a',
|
|
7
|
+
Spinner: 'svg',
|
|
8
|
+
Radio: 'input',
|
|
9
|
+
TextInput: {
|
|
10
|
+
Action: 'button',
|
|
11
|
+
self: 'input'
|
|
12
|
+
},
|
|
13
|
+
...passedObj
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('getElementType', function() {
|
|
18
|
+
it('flattens passed object 1-level deep', function() {
|
|
19
|
+
const result = flattenComponents(mockComponents())
|
|
20
|
+
|
|
21
|
+
const expectedResult = {
|
|
22
|
+
Button: 'button',
|
|
23
|
+
Link: 'a',
|
|
24
|
+
Spinner: 'svg',
|
|
25
|
+
Radio: 'input',
|
|
26
|
+
TextInput: 'input',
|
|
27
|
+
'TextInput.Action': 'button'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual(expectedResult)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('ignores objects nested deeper than 1-level', function() {
|
|
34
|
+
const result = flattenComponents(
|
|
35
|
+
mockComponents({
|
|
36
|
+
Select: {
|
|
37
|
+
Items: {
|
|
38
|
+
self: 'div'
|
|
39
|
+
},
|
|
40
|
+
Option: 'option',
|
|
41
|
+
self: 'select'
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const expectedResult = {
|
|
47
|
+
Button: 'button',
|
|
48
|
+
Link: 'a',
|
|
49
|
+
Spinner: 'svg',
|
|
50
|
+
Radio: 'input',
|
|
51
|
+
TextInput: 'input',
|
|
52
|
+
'TextInput.Action': 'button',
|
|
53
|
+
'Select.Items': {
|
|
54
|
+
self: 'div'
|
|
55
|
+
},
|
|
56
|
+
Select: 'select',
|
|
57
|
+
'Select.Option': 'option'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
expect(result).toEqual(expectedResult)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function flattenComponents(componentObj) {
|
|
2
|
+
let result = {};
|
|
3
|
+
|
|
4
|
+
Object.keys(componentObj).forEach((key) => {
|
|
5
|
+
if (typeof componentObj[key] === 'object') {
|
|
6
|
+
const test = Object.keys(componentObj[key]).forEach((item) => {
|
|
7
|
+
result = { ...result, [`${key}${item !== 'self' ? `.${item}` : ''}`]: componentObj[key][item] };
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
result = {...result, [key]: componentObj[key]}
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
exports.flattenComponents = flattenComponents
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function getJSXOpeningElementAttribute(openingEl, name) {
|
|
2
|
+
const attributes = openingEl.attributes
|
|
3
|
+
const attribute = attributes.find(attribute => {
|
|
4
|
+
return attribute.name.name === name
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
return attribute
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
exports.getJSXOpeningElementAttribute = getJSXOpeningElementAttribute
|