eslint-plugin-primer-react 3.0.0 → 4.0.0-rc.1e980db

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-primer-react",
3
- "version": "3.0.0",
3
+ "version": "4.0.0-rc.1e980db",
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": "^5.1.0",
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.3.7",
39
- "eslint-plugin-jsx-a11y": "^6.6.1",
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,23 @@
1
+ const { flattenComponents } = require('../utils/flatten-components');
2
+
3
+ const components = flattenComponents({
4
+ Button: 'button',
5
+ IconButton: 'button',
6
+ ToggleSwitch: 'button',
7
+ Radio: 'input',
8
+ Checkbox: 'input',
9
+ Text: 'span',
10
+ TextInput: {
11
+ Action: 'button',
12
+ self: 'input',
13
+ },
14
+ Select: {
15
+ Option: 'option',
16
+ self: 'select',
17
+ },
18
+ TabNav: {
19
+ self: 'nav',
20
+ },
21
+ })
22
+
23
+ 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,63 @@
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
+ import {Heading} from '@primer/react';
39
+ <Heading
40
+ {...someProps}
41
+ as="h3"
42
+ >
43
+ Passed spread props
44
+ </Heading>
45
+ `
46
+
47
+ ],
48
+ invalid: [
49
+ {
50
+ code:
51
+ `import {Heading} from '@primer/react';
52
+ <Heading>Heading without "as"</Heading>`,
53
+ errors: [{ messageId: 'nonExplicitHeadingLevel' }]
54
+ },
55
+ {
56
+ code:
57
+ `import {Heading} from '@primer/react';
58
+ <Heading as="span">Heading component used as "span"</Heading>
59
+ `,
60
+ errors: [{ messageId: 'invalidAsValue' }]
61
+ },
62
+ ]
63
+ })
@@ -0,0 +1,218 @@
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
+ import {Tooltip, Link} from '@primer/react';
57
+ <Tooltip aria-label={avatar.avatarName} direction="e">
58
+ <Link href={avatar.avatarLink} underline={true}>
59
+ User avatar
60
+ </Link>
61
+ </Tooltip>` ,
62
+ `
63
+ import {Tooltip, Link} from '@primer/react';
64
+ <Tooltip aria-label="product" direction="e">
65
+ <Link href={productLink}>
66
+ Product
67
+ </Link>
68
+ </Tooltip>
69
+ `
70
+ ],
71
+ invalid: [
72
+ {
73
+ code: `import {Tooltip} from '@primer/react';<Tooltip type="description" text="supportive text" direction="e"><button>button1</button><button>button2</button></Tooltip>
74
+ `,
75
+ errors: [
76
+ {
77
+ messageId: 'singleChild'
78
+ }
79
+ ]
80
+ },
81
+ {
82
+ code: `
83
+ import {Tooltip} from '@primer/react';
84
+ <Tooltip aria-label="Filter vegetarian options" direction="e">
85
+ <span>non interactive element</span>
86
+ </Tooltip>
87
+ `,
88
+ errors: [
89
+ {
90
+ messageId: 'nonInteractiveTrigger'
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ code: `
96
+ import {Tooltip, Button} from '@primer/react';
97
+ <Tooltip aria-label="Supplementary text" direction="e">
98
+ <h1>Save</h1>
99
+ </Tooltip>`,
100
+ errors: [
101
+ {
102
+ messageId: 'nonInteractiveTrigger'
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ code: `
108
+ import {Tooltip} from '@primer/react';
109
+ <Tooltip aria-label="Supplementary text" direction="e">
110
+ <a>see commit message</a>
111
+ </Tooltip>`,
112
+ errors: [
113
+ {
114
+ messageId: 'nonInteractiveLink'
115
+ }
116
+ ]
117
+ },
118
+ {
119
+ code: `
120
+ import {Tooltip, Link} from '@primer/react';
121
+ <Tooltip aria-label="Supplementary text" direction="e">
122
+ <Link>see commit message</Link>
123
+ </Tooltip>`,
124
+ errors: [
125
+ {
126
+ messageId: 'nonInteractiveLink'
127
+ }
128
+ ]
129
+ },
130
+ {
131
+ code: `
132
+ import {Tooltip} from '@primer/react';
133
+ <Tooltip aria-label="Supplementary text" direction="e">
134
+ <input type="hidden" />
135
+ </Tooltip>`,
136
+ errors: [
137
+ {
138
+ messageId: 'nonInteractiveInput'
139
+ }
140
+ ]
141
+ },
142
+ {
143
+ code: `
144
+ import {Tooltip, TextInput} from '@primer/react';
145
+ <Tooltip aria-label="Supplementary text" direction="e">
146
+ <TextInput type="hidden" aria-label="Zipcode" name="zipcode" placeholder="Zipcode" autoComplete="postal-code" />
147
+ </Tooltip>`,
148
+ errors: [
149
+ {
150
+ messageId: 'nonInteractiveInput'
151
+ }
152
+ ]
153
+ },
154
+ {
155
+ code: `
156
+ import {Tooltip, Button} from '@primer/react';
157
+ <Tooltip aria-label="Supplementary text" direction="e">
158
+ <Button disabled>Save</Button>
159
+ </Tooltip>`,
160
+ errors: [
161
+ {
162
+ messageId: 'nonInteractiveTrigger'
163
+ }
164
+ ]
165
+ },
166
+ {
167
+ code: `
168
+ import {Tooltip, Button} from '@primer/react';
169
+ <Tooltip aria-label="Supplementary text" direction="e">
170
+ <IconButton disabled>Save</IconButton>
171
+ </Tooltip>`,
172
+ errors: [
173
+ {
174
+ messageId: 'nonInteractiveTrigger'
175
+ }
176
+ ]
177
+ },
178
+ {
179
+ code: `
180
+ import {Tooltip, Button} from '@primer/react';
181
+ <Tooltip aria-label="Supplementary text" direction="e">
182
+ <input disabled>Save</input>
183
+ </Tooltip>`,
184
+ errors: [
185
+ {
186
+ messageId: 'nonInteractiveInput'
187
+ }
188
+ ]
189
+ },
190
+ {
191
+ code: `
192
+ import {Tooltip, Button} from '@primer/react';
193
+ <Tooltip aria-label="Supplementary text" direction="e">
194
+ <header>
195
+ <span>Save</span>
196
+ </header>
197
+ </Tooltip>`,
198
+ errors: [
199
+ {
200
+ messageId: 'nonInteractiveTrigger'
201
+ }
202
+ ]
203
+ },
204
+ {
205
+ code: `import {Tooltip, Button} from '@primer/react';
206
+ <Tooltip aria-label="Supplementary text" direction="e">
207
+ <h1>
208
+ <a>Save</a>
209
+ </h1>
210
+ </Tooltip>`,
211
+ errors: [
212
+ {
213
+ messageId: 'nonInteractiveLink'
214
+ }
215
+ ]
216
+ }
217
+ ]
218
+ })
@@ -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,188 @@
1
+ const {getPropValue, propName} = require('jsx-ast-utils')
2
+ const {isPrimerComponent} = require('../utils/is-primer-component')
3
+ const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
4
+ const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
5
+
6
+ const isInteractive = child => {
7
+ const childName = getJSXOpeningElementName(child.openingElement)
8
+ return (
9
+ ['button', 'summary', 'select', 'textarea', 'a', 'input', 'link', 'iconbutton', 'textinput'].includes(
10
+ childName.toLowerCase()
11
+ ) && !hasDisabledAttr(child)
12
+ )
13
+ }
14
+
15
+ const hasDisabledAttr = child => {
16
+ const hasDisabledAttr = getJSXOpeningElementAttribute(child.openingElement, 'disabled')
17
+ return hasDisabledAttr
18
+ }
19
+
20
+ const isAnchorTag = el => {
21
+ const openingEl = getJSXOpeningElementName(el.openingElement)
22
+ return openingEl === 'a' || openingEl.toLowerCase() === 'link'
23
+ }
24
+
25
+ const isJSXValue = (attributes) => {
26
+ const node = attributes.find(attribute => propName(attribute) === 'href');
27
+ const isJSXExpression = node.value.type === 'JSXExpressionContainer' && node
28
+ && typeof getPropValue(node) === 'string';
29
+
30
+ return isJSXExpression
31
+ }
32
+
33
+ const isInteractiveAnchor = child => {
34
+ const hasHref = getJSXOpeningElementAttribute(child.openingElement, 'href')
35
+ if (!hasHref) return false
36
+ const href = getJSXOpeningElementAttribute(child.openingElement, 'href').value.value
37
+ const hasJSXValue = isJSXValue(child.openingElement.attributes);
38
+ const isAnchorInteractive = (typeof href === 'string' && href !== '' || hasJSXValue)
39
+
40
+ return isAnchorInteractive
41
+ }
42
+
43
+ const isInputTag = el => {
44
+ const openingEl = getJSXOpeningElementName(el.openingElement)
45
+ return openingEl === 'input' || openingEl.toLowerCase() === 'textinput'
46
+ }
47
+
48
+ const isInteractiveInput = child => {
49
+ const hasHiddenType =
50
+ getJSXOpeningElementAttribute(child.openingElement, 'type') &&
51
+ getJSXOpeningElementAttribute(child.openingElement, 'type').value.value === 'hidden'
52
+ return !hasHiddenType && !hasDisabledAttr(child)
53
+ }
54
+
55
+ const isOtherThanAnchorOrInput = el => {
56
+ return !isAnchorTag(el) && !isInputTag(el)
57
+ }
58
+
59
+ const getAllChildren = node => {
60
+ if (Array.isArray(node.children)) {
61
+ return node.children
62
+ .filter(child => {
63
+ return child.type === 'JSXElement'
64
+ })
65
+ .flatMap(child => {
66
+ return [child, ...getAllChildren(child)]
67
+ })
68
+ }
69
+ return []
70
+ }
71
+
72
+ const checks = [
73
+ {
74
+ id: 'nonInteractiveLink',
75
+ filter: jsxElement => isAnchorTag(jsxElement),
76
+ check: isInteractiveAnchor
77
+ },
78
+ {
79
+ id: 'nonInteractiveInput',
80
+ filter: jsxElement => isInputTag(jsxElement),
81
+ check: isInteractiveInput
82
+ },
83
+ {
84
+ id: 'nonInteractiveTrigger',
85
+ filter: jsxElement => isOtherThanAnchorOrInput(jsxElement),
86
+ check: isInteractive
87
+ }
88
+ ]
89
+
90
+ const checkTriggerElement = jsxNode => {
91
+ const elements = [...getAllChildren(jsxNode)]
92
+ const hasInteractiveElement = elements.find(element => {
93
+ const openingEl = getJSXOpeningElementName(element.openingElement)
94
+ if (openingEl === 'a' || openingEl === 'Link') {
95
+ return isInteractiveAnchor(element)
96
+ }
97
+ if (openingEl === 'input' || openingEl === 'TextInput') {
98
+ return isInteractiveInput(element)
99
+ } else {
100
+ return isInteractive(element)
101
+ }
102
+ })
103
+
104
+ // If the tooltip has interactive elements, return.
105
+ if (hasInteractiveElement) return
106
+
107
+ const errors = new Set()
108
+
109
+ for (const element of elements) {
110
+ for (const check of checks) {
111
+ if (!check.filter(element)) {
112
+ continue
113
+ }
114
+
115
+ if (!check.check(element)) {
116
+ errors.add(check.id)
117
+ }
118
+ }
119
+ }
120
+ // check the specificity of the errors. If there are multiple errors, only return the most specific one.
121
+ if (errors.size > 1) {
122
+ if (errors.has('nonInteractiveLink')) {
123
+ errors.delete('nonInteractiveTrigger')
124
+ }
125
+ if (errors.has('nonInteractiveInput')) {
126
+ errors.delete('nonInteractiveTrigger')
127
+ }
128
+ }
129
+
130
+ return errors
131
+ }
132
+
133
+ module.exports = {
134
+ meta: {
135
+ type: 'problem',
136
+ schema: [
137
+ {
138
+ properties: {
139
+ skipImportCheck: {
140
+ type: 'boolean'
141
+ }
142
+ }
143
+ }
144
+ ],
145
+ messages: {
146
+ nonInteractiveTrigger:
147
+ 'Tooltips should only be applied to interactive elements that are not disabled. Consider using a `<button>` or equivalent interactive element instead.',
148
+ nonInteractiveLink:
149
+ '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',
150
+ nonInteractiveInput:
151
+ 'Hidden or disabled 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',
152
+ singleChild: 'The `Tooltip` component expects a single React element as a child.'
153
+ }
154
+ },
155
+ create(context) {
156
+ return {
157
+ JSXElement(jsxNode) {
158
+ // If `skipImportCheck` is true, this rule will check for non-interactive element in any components (not just ones that are imported from `@primer/react`).
159
+ const skipImportCheck = context.options[0] ? context.options[0].skipImportCheck : false
160
+ const name = getJSXOpeningElementName(jsxNode.openingElement)
161
+ if (
162
+ (skipImportCheck || isPrimerComponent(jsxNode.openingElement.name, context.getScope(jsxNode))) &&
163
+ name === 'Tooltip' &&
164
+ jsxNode.children
165
+ ) {
166
+ if (jsxNode.children.filter(child => child.type === 'JSXElement').length > 1) {
167
+ context.report({
168
+ node: jsxNode,
169
+ messageId: 'singleChild'
170
+ })
171
+ } else {
172
+ // Check if the child is interactive
173
+ const errors = checkTriggerElement(jsxNode)
174
+
175
+ if (errors) {
176
+ for (const [key, value] of errors.entries()) {
177
+ context.report({
178
+ node: jsxNode,
179
+ messageId: value
180
+ })
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
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
+ }