Skip to content

Commit 015237e

Browse files
authored
Merge pull request #39 from primer/slots-rule
2 parents 191d35e + 0bda0ed commit 015237e

File tree

8 files changed

+190
-5
lines changed

8 files changed

+190
-5
lines changed

.changeset/soft-rabbits-stare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-primer-react": major
3+
---
4+
5+
Add `direct-slot-children` rule

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ ESLint rules for Primer React
2929

3030
## Rules
3131

32+
- [direct-slot-children](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/direct-slot-children.md)
3233
- [no-deprecated-colors](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-deprecated-colors.md)
3334
- [no-system-props](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-system-props.md)

docs/rules/direct-slot-children.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Enforce direct parent-child relationship of slot components (direct-slot-children)
2+
3+
Some Primer React components use a slots pattern under the hood to render subcomponents in specific places. For example, the `PageLayout` component renders `PageLayout.Header` in the header area, and `PageLayout.Footer` in the footer area. These subcomponents must be direct children of the parent component, and cannot be nested inside other components.
4+
5+
## Rule details
6+
7+
This rule enforces that slot components are direct children of their parent component.
8+
9+
👎 Examples of **incorrect** code for this rule:
10+
11+
```jsx
12+
/* eslint primer-react/direct-slot-children: "error" */
13+
import {PageLayout} from '@primer/react'
14+
15+
const MyHeader = () => <PageLayout.Header>Header</PageLayout.Header>
16+
17+
const App = () => (
18+
<PageLayout>
19+
<MyHeader />
20+
</PageLayout>
21+
)
22+
```
23+
24+
👍 Examples of **correct** code for this rule:
25+
26+
```jsx
27+
/* eslint primer-react/direct-slot-children: "error" */
28+
import {PageLayout} from '@primer/react'
29+
30+
const MyHeader = () => <div>Header</div>
31+
32+
const App = () => (
33+
<PageLayout>
34+
<PageLayout.Header>
35+
<MyHeader />
36+
</PageLayout.Header>
37+
</PageLayout>
38+
)
39+
```

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
"jest": "^27.0.6"
3030
},
3131
"peerDependencies": {
32-
"eslint": "^8.0.1",
33-
"@primer/primitives": ">=4.6.2"
32+
"@primer/primitives": ">=4.6.2",
33+
"eslint": "^8.0.1"
3434
},
3535
"prettier": "@github/prettier-config",
3636
"dependencies": {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const rule = require('../direct-slot-children')
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('direct-slot-children', rule, {
15+
valid: [
16+
`import {PageLayout} from '@primer/react'; <PageLayout><PageLayout.Header>Header</PageLayout.Header><PageLayout.Footer>Footer</PageLayout.Footer></PageLayout>`,
17+
`import {PageLayout} from '@primer/react'; <PageLayout><div><PageLayout.Pane>Header</PageLayout.Pane></div></PageLayout>`,
18+
`import {PageLayout} from 'some-library'; <PageLayout.Header>Header</PageLayout.Header>`
19+
],
20+
invalid: [
21+
{
22+
code: `import {PageLayout} from '@primer/react'; <PageLayout.Header>Header</PageLayout.Header>`,
23+
errors: [
24+
{
25+
messageId: 'directSlotChildren',
26+
data: {childName: 'PageLayout.Header', parentName: 'PageLayout'}
27+
}
28+
]
29+
},
30+
{
31+
code: `import {PageLayout} from '@primer/react/drafts'; <PageLayout.Header>Header</PageLayout.Header>`,
32+
errors: [
33+
{
34+
messageId: 'directSlotChildren',
35+
data: {childName: 'PageLayout.Header', parentName: 'PageLayout'}
36+
}
37+
]
38+
},
39+
{
40+
code: `import {PageLayout} from '@primer/react'; <div><PageLayout.Header>Header</PageLayout.Header></div>`,
41+
errors: [
42+
{
43+
messageId: 'directSlotChildren',
44+
data: {childName: 'PageLayout.Header', parentName: 'PageLayout'}
45+
}
46+
]
47+
},
48+
{
49+
code: `import {PageLayout} from '@primer/react'; <PageLayout><div><PageLayout.Header>Header</PageLayout.Header></div></PageLayout>`,
50+
errors: [
51+
{
52+
messageId: 'directSlotChildren',
53+
data: {childName: 'PageLayout.Header', parentName: 'PageLayout'}
54+
}
55+
]
56+
},
57+
{
58+
code: `import {TreeView} from '@primer/react'; <TreeView><TreeView.Item><div><TreeView.LeadingVisual>Visual</TreeView.LeadingVisual></div></TreeView.Item></TreeView>`,
59+
errors: [
60+
{
61+
messageId: 'directSlotChildren',
62+
data: {childName: 'TreeView.LeadingVisual', parentName: 'TreeView.Item'}
63+
}
64+
]
65+
}
66+
]
67+
})

src/rules/direct-slot-children.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const {isPrimerComponent} = require('../utils/is-primer-component')
2+
3+
const slotParentToChildMap = {
4+
PageLayout: ['PageLayout.Header', 'PageLayout.Footer'],
5+
FormControl: ['FormControl.Label', 'FormControl.Caption', 'FormControl.LeadingVisual', 'FormControl.TrailingVisual'],
6+
MarkdownEditor: ['MarkdownEditor.Toolbar', 'MarkdownEditor.Actions', 'MarkdownEditor.Label'],
7+
'ActionList.Item': ['ActionList.LeadingVisual', 'ActionList.TrailingVisual', 'ActionList.Description'],
8+
'TreeView.Item': ['TreeView.LeadingVisual', 'TreeView.TrailingVisual'],
9+
RadioGroup: ['RadioGroup.Label', 'RadioGroup.Caption', 'RadioGroup.Validation'],
10+
CheckboxGroup: ['CheckboxGroup.Label', 'CheckboxGroup.Caption', 'CheckboxGroup.Validation']
11+
}
12+
13+
const slotChildToParentMap = Object.entries(slotParentToChildMap).reduce((acc, [parent, children]) => {
14+
for (const child of children) {
15+
acc[child] = parent
16+
}
17+
return acc
18+
}, {})
19+
20+
module.exports = {
21+
meta: {
22+
type: 'problem',
23+
schema: [],
24+
messages: {
25+
directSlotChildren: '{{childName}} must be a direct child of {{parentName}}.'
26+
}
27+
},
28+
create(context) {
29+
return {
30+
JSXOpeningElement(jsxNode) {
31+
const name = getJSXOpeningElementName(jsxNode)
32+
33+
// If component is a Primer component and a slot child,
34+
// check if it's a direct child of the slot parent
35+
if (isPrimerComponent(jsxNode.name, context.getScope(jsxNode)) && slotChildToParentMap[name]) {
36+
const JSXElement = jsxNode.parent
37+
const parent = JSXElement.parent
38+
39+
const expectedParentName = slotChildToParentMap[name]
40+
if (parent.type !== 'JSXElement' || getJSXOpeningElementName(parent.openingElement) !== expectedParentName) {
41+
context.report({
42+
node: jsxNode,
43+
messageId: 'directSlotChildren',
44+
data: {childName: name, parentName: expectedParentName}
45+
})
46+
}
47+
}
48+
}
49+
}
50+
}
51+
}
52+
53+
// Convert JSXOpeningElement name to string
54+
function getJSXOpeningElementName(jsxNode) {
55+
if (jsxNode.name.type === 'JSXIdentifier') {
56+
return jsxNode.name.name
57+
} else if (jsxNode.name.type === 'JSXMemberExpression') {
58+
return `${jsxNode.name.object.name}.${jsxNode.name.property.name}`
59+
}
60+
}

src/utils/is-primer-component.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
const {isImportedFrom} = require('./is-imported-from')
22

3-
function isPrimerComponent(identifier, scope) {
3+
function isPrimerComponent(name, scope) {
4+
let identifier
5+
6+
switch (name.type) {
7+
case 'JSXIdentifier':
8+
identifier = name
9+
break
10+
case 'JSXMemberExpression':
11+
identifier = name.object
12+
break
13+
default:
14+
return false
15+
}
16+
417
return isImportedFrom(/^@primer\/react/, identifier, scope)
518
}
619
exports.isPrimerComponent = isPrimerComponent

0 commit comments

Comments
 (0)