Skip to content

Commit 4bdf7f9

Browse files
Add an eslint rule for checking interactivity of tooltip trigger
1 parent b2ee59f commit 4bdf7f9

File tree

3 files changed

+218
-0
lines changed

3 files changed

+218
-0
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
const rule = require('../non-interactive-tooltip-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';<Tooltip aria-label="Filter vegetarian options" direction="e"><Button>🥦</Button></Tooltip>`,
17+
`import {Tooltip, Button} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><Button>Save</Button></Tooltip>`,
18+
`import {Tooltip, IconButton} from '@primer/react';import {SearchIcon} from '@primer/octicons-react';<Tooltip aria-label="Supplementary text" direction="e"><IconButton icon={SearchIcon} aria-label="Search" /></Tooltip>`,
19+
`import {Tooltip, Button} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><div><Button>Save</Button></div></Tooltip>`,
20+
`import {Tooltip, Button} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><div><a href="https://gthub.com">Save</a></div></Tooltip>`,
21+
`import {Tooltip} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><a href="https://github.com">see commit message</a></Tooltip>`
22+
],
23+
invalid: [
24+
{
25+
code: `import {Tooltip} from '@primer/react';<Tooltip type="description" text="supportive text" direction="e"><button>save</button><button>cancel</button></Tooltip>`,
26+
errors: [
27+
{
28+
messageId: 'singleChild'
29+
}
30+
]
31+
},
32+
{
33+
code: `import {Tooltip} from '@primer/react';<Tooltip aria-label="Filter vegetarian options" direction="e"><span>non interactive element</span></Tooltip>`,
34+
errors: [
35+
{
36+
messageId: 'nonInteractiveTrigger'
37+
}
38+
]
39+
},
40+
{
41+
code: `import {Tooltip, Button} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><h1>Save</h1></Tooltip>`,
42+
errors: [
43+
{
44+
messageId: 'nonInteractiveTrigger'
45+
}
46+
]
47+
},
48+
{
49+
code: `import {Tooltip} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><a>see commit message</a></Tooltip>`,
50+
errors: [
51+
{
52+
messageId: 'anchorTagWithoutHref'
53+
}
54+
]
55+
},
56+
{
57+
code: `import {Tooltip} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><input type="hidden" /></Tooltip>`,
58+
errors: [
59+
{
60+
messageId: 'hiddenInput'
61+
}
62+
]
63+
},
64+
{
65+
code: `import {Tooltip, Button} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><heading><span>Save</span></heading></Tooltip>`,
66+
errors: [
67+
{
68+
messageId: 'nonInteractiveTrigger'
69+
}
70+
]
71+
},
72+
{
73+
code: `import {Tooltip, Button} from '@primer/react';<Tooltip aria-label="Supplementary text" direction="e"><h1><a>Save</a></h1></Tooltip>`,
74+
errors: [
75+
{
76+
messageId: 'nonInteractiveTrigger'
77+
}
78+
]
79+
}
80+
]
81+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
const {isPrimerComponent} = require('../utils/is-primer-component')
2+
const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
3+
const {getJSXOpeningElementAttribute} = require('../utils/get-attribute')
4+
5+
isInteractive = child => {
6+
const childName = getJSXOpeningElementName(child.openingElement)
7+
return ['button', 'summary', 'select', 'textarea', 'a', 'input', 'iconbutton'].includes(childName.toLowerCase())
8+
}
9+
10+
isInteractiveAnchor = child => {
11+
const hasHref = getJSXOpeningElementAttribute(child.openingElement, 'href')
12+
if (!hasHref) return false
13+
const href = getJSXOpeningElementAttribute(child.openingElement, 'href').value.value
14+
const isAnchorInteractive = typeof href === 'string' && href !== ''
15+
return isAnchorInteractive
16+
}
17+
18+
isInteractiveInput = child => {
19+
const hasHiddenType =
20+
getJSXOpeningElementAttribute(child.openingElement, 'type') &&
21+
getJSXOpeningElementAttribute(child.openingElement, 'type').value.value === 'hidden'
22+
return !hasHiddenType
23+
}
24+
25+
const checkTriggerElement = jsxNode => {
26+
let messageId = ''
27+
const child = jsxNode.children
28+
const childName = getJSXOpeningElementName(child.openingElement)
29+
30+
// First check specific requirements for anchor
31+
if (childName === 'a' && !isInteractiveAnchor(child)) {
32+
messageId = 'anchorTagWithoutHref'
33+
return {messageId, node: jsxNode}
34+
}
35+
// Then check specific requirements input
36+
if (childName === 'input' && !isInteractiveInput(child)) {
37+
messageId = 'hiddenInput'
38+
return {messageId, node: jsxNode}
39+
}
40+
// Then check if the child is interactive
41+
if (!isInteractive(child)) {
42+
// If child is not interactive, check if there are any grandchildren that is interactive
43+
const hasJsxGrands =
44+
child.children.length > 0 && child.children.filter(gChild => gChild.type === 'JSXElement').length > 0 //.some(gChild => isInteractive(gChild))
45+
46+
if (!hasJsxGrands) {
47+
messageId = 'nonInteractiveTrigger'
48+
} else {
49+
const hasInteractiveGrands = child.children // is there any way I can access all child nodes? :/
50+
.filter(gChild => gChild.type === 'JSXElement')
51+
.some(gChild => {
52+
const gChildName = getJSXOpeningElementName(gChild.openingElement)
53+
// TODO: How can I check all child nodes?
54+
return checkTriggerElement(gChild).messageId === ''
55+
})
56+
if (!hasInteractiveGrands) messageId = 'nonInteractiveTrigger'
57+
}
58+
59+
return {messageId, node: jsxNode}
60+
}
61+
62+
// All good the element is interactive
63+
return {messageId, node: jsxNode}
64+
}
65+
66+
module.exports = {
67+
meta: {
68+
type: 'problem',
69+
docs: {
70+
url:
71+
'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/no-noninteractive-element-interactions.md',
72+
description: 'Non-interactive elements should not be assigned mouse or keyboard event listeners.'
73+
},
74+
schema: [
75+
{
76+
properties: {
77+
skipImportCheck: {
78+
type: 'boolean'
79+
}
80+
}
81+
}
82+
],
83+
messages: {
84+
nonInteractiveTrigger:
85+
'The `Tooltip` component expects a single React element that contains interactive content. Consider using a `<button>` or equivalent interactive element instead.',
86+
anchorTagWithoutHref:
87+
'Anchor tags without an href attribute are not be considered interactive, therefore can not be used as a trigger for a tooltip.',
88+
hiddenInput:
89+
'Hidden inputs are not be considered interactive, therefore can not be used as a trigger for a tooltip.',
90+
singleChild: 'The `Tooltip` component expects a single React element as a child.'
91+
}
92+
},
93+
create(context) {
94+
const stack = []
95+
const {options} = context
96+
return {
97+
JSXElement(jsxNode) {
98+
// If `skipImportCheck` is true, this rule will check for direct slot children
99+
// in any components (not just ones that are imported from `@primer/react`).
100+
const skipImportCheck = context.options[0] ? context.options[0].skipImportCheck : false
101+
102+
const name = getJSXOpeningElementName(jsxNode.openingElement)
103+
104+
if (name === 'Tooltip' && jsxNode.children) {
105+
// Check if there is a single child
106+
if (jsxNode.children.length > 1) {
107+
context.report({
108+
node: jsxNode,
109+
messageId: 'singleChild'
110+
})
111+
} else {
112+
// Check if the child is interactive
113+
const {node, messageId} = checkTriggerElement(jsxNode)
114+
115+
if (messageId !== '') {
116+
context.report({
117+
node,
118+
messageId
119+
})
120+
}
121+
}
122+
}
123+
}
124+
}
125+
}
126+
}

src/utils/get-attribute.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
function getJSXOpeningElementAttribute(openingEl, name) {
2+
const attributes = openingEl.attributes
3+
const attribute = attributes.find(attribute => {
4+
// console.log('hey attribute', attribute)
5+
return attribute.name.name === name
6+
})
7+
8+
return attribute
9+
}
10+
11+
exports.getJSXOpeningElementAttribute = getJSXOpeningElementAttribute

0 commit comments

Comments
 (0)