eslint-plugin-primer-react 3.0.0 → 4.0.0-rc.b9f7dc6

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.b9f7dc6",
4
4
  "description": "ESLint rules for Primer React",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -10,7 +10,8 @@ module.exports = {
10
10
  rules: {
11
11
  'primer-react/direct-slot-children': 'error',
12
12
  'primer-react/no-deprecated-colors': 'warn',
13
- 'primer-react/no-system-props': 'warn'
13
+ 'primer-react/no-system-props': 'warn',
14
+ 'primer-react/a11y-tooltip-interactive-trigger': 'error'
14
15
  },
15
16
  settings: {
16
17
  github: {
package/src/index.js CHANGED
@@ -2,7 +2,8 @@ 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')
6
7
  },
7
8
  configs: {
8
9
  recommended: require('./configs/recommended')
@@ -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,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
+ }
@@ -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