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/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.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": "^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,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