}',
102 | options: [{casing: 'camel'}],
103 | errors: [
104 | {
105 | messageId: 'bad',
106 | data: {type: 'Identifier'},
107 | },
108 | ],
109 | },
110 | ],
111 | })
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # eslint-plugin-primer-react
2 |
3 | [](https://www.npmjs.com/package/eslint-plugin-primer-react)
4 |
5 | ESLint rules for Primer React
6 |
7 | ## Installation
8 |
9 | 1. Assuming you already have [ESLint](https://www.npmjs.com/package/eslint) and
10 | [Primer React](https://github.com/primer/react) installed, run:
11 |
12 | ```shell
13 | npm install --save-dev eslint-plugin-primer-react
14 |
15 | # or
16 |
17 | yarn add --dev eslint-plugin-primer-react
18 | ```
19 |
20 | 2. In your [ESLint configuration file](https://eslint.org/docs/user-guide/configuring/configuration-files), extend the
21 | recommended Primer React ESLint config:
22 |
23 | ```js
24 | {
25 | "extends": [
26 | // ...
27 | "plugin:primer-react/recommended"
28 | ]
29 | }
30 | ```
31 |
32 | ## Rules
33 |
34 | - [a11y-explicit-heading](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/a11y-explicit-heading.md)
35 | - [a11y-link-in-text-block](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/a11y-link-in-text-block.md)
36 | - [a11y-no-duplicate-form-labels](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/a11y-no-duplicate-form-labels.md)
37 | - [a11y-no-title-usage](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/a11y-no-title-usage.md)
38 | - [a11y-remove-disable-tooltip](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/a11y-remove-disable-tooltip.md)
39 | - [a11y-tooltip-interactive-trigger](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/a11y-tooltip-interactive-trigger.md)
40 | - [a11y-use-accessible-tooltip](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/a11y-use-accessible-tooltip.md)
41 | - [direct-slot-children](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/direct-slot-children.md)
42 | - [enforce-button-for-link-with-no-href](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/enforce-button-for-link-with-no-href.md)
43 | - [enforce-css-module-default-import](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/enforce-css-module-default-import.md)
44 | - [enforce-css-module-identifier-casing](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/enforce-css-module-identifier-casing.md)
45 | - [new-color-css-vars](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/new-color-css-vars.md)
46 | - [no-deprecated-entrypoints](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-deprecated-entrypoints.md)
47 | - [no-deprecated-experimental-components](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-deprecated-experimental-components.md)
48 | - [no-deprecated-props](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-deprecated-props.md)
49 | - [no-system-props](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-system-props.md)
50 | - [no-unnecessary-components](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-unnecessary-components.md)
51 | - [no-use-responsive-value](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-use-responsive-value.md)
52 | - [no-wildcard-imports](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/no-wildcard-imports.md)
53 | - [prefer-action-list-item-onselect](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/prefer-action-list-item-onselect.md)
54 | - [spread-props-first](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/spread-props-first.md)
55 | - [use-deprecated-from-deprecated](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/use-deprecated-from-deprecated.md)
56 | - [use-styled-react-import](https://github.com/primer/eslint-plugin-primer-react/blob/main/docs/rules/use-styled-react-import.md)
57 |
--------------------------------------------------------------------------------
/src/rules/__tests__/no-use-responsive-value.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const {RuleTester} = require('eslint')
4 | const rule = require('../no-use-responsive-value')
5 |
6 | const ruleTester = new RuleTester({
7 | languageOptions: {
8 | ecmaVersion: 'latest',
9 | sourceType: 'module',
10 | parserOptions: {
11 | ecmaFeatures: {
12 | jsx: true,
13 | },
14 | },
15 | },
16 | })
17 |
18 | ruleTester.run('no-use-responsive-value', rule, {
19 | valid: [
20 | // Valid - not importing useResponsiveValue
21 | `import { Button } from '@primer/react'`,
22 |
23 | // Valid - importing from other modules
24 | `import { useResponsiveValue } from 'other-module'`,
25 |
26 | // Valid - using other hooks from @primer/react
27 | `import { useTheme } from '@primer/react'`,
28 |
29 | // Valid - function with same name but not imported from @primer/react
30 | `function useResponsiveValue() { return 'custom' }`,
31 |
32 | // Valid - importing from unrelated local paths
33 | `import { something } from '../utils/helpers'`,
34 |
35 | // Valid - importing other hooks from local paths
36 | `import { useCustomHook } from '../hooks/useCustomHook'`,
37 | ],
38 | invalid: [
39 | // Invalid - importing useResponsiveValue from @primer/react
40 | {
41 | code: `import { useResponsiveValue } from '@primer/react'`,
42 | errors: [
43 | {
44 | messageId: 'noUseResponsiveValue',
45 | },
46 | ],
47 | },
48 |
49 | // Invalid - importing with other imports
50 | {
51 | code: `import { Button, useResponsiveValue, Box } from '@primer/react'`,
52 | errors: [
53 | {
54 | messageId: 'noUseResponsiveValue',
55 | },
56 | ],
57 | },
58 |
59 | // Invalid - importing as named import with alias
60 | {
61 | code: `import { useResponsiveValue as useRV } from '@primer/react'
62 | function Component() {
63 | const value = useRV(['sm', 'md'])
64 | return {value}
65 | }`,
66 | errors: [
67 | {
68 | messageId: 'noUseResponsiveValue',
69 | },
70 | ],
71 | },
72 |
73 | // Invalid - importing from experimental entrypoint
74 | {
75 | code: `import { useResponsiveValue } from '@primer/react/experimental'`,
76 | errors: [
77 | {
78 | messageId: 'noUseResponsiveValue',
79 | },
80 | ],
81 | },
82 |
83 | // Invalid - importing from deprecated entrypoint
84 | {
85 | code: `import { useResponsiveValue } from '@primer/react/deprecated'`,
86 | errors: [
87 | {
88 | messageId: 'noUseResponsiveValue',
89 | },
90 | ],
91 | },
92 |
93 | // Invalid - importing from local hooks path
94 | {
95 | code: `import { useResponsiveValue } from '../hooks/useResponsiveValue'`,
96 | errors: [
97 | {
98 | messageId: 'noUseResponsiveValue',
99 | },
100 | ],
101 | },
102 |
103 | // Invalid - importing default from local useResponsiveValue file
104 | {
105 | code: `import useResponsiveValue from '../hooks/useResponsiveValue'`,
106 | errors: [
107 | {
108 | messageId: 'noUseResponsiveValue',
109 | },
110 | ],
111 | },
112 |
113 | // Invalid - importing from nested path containing useResponsiveValue
114 | {
115 | code: `import { useResponsiveValue } from '../../src/hooks/useResponsiveValue'`,
116 | errors: [
117 | {
118 | messageId: 'noUseResponsiveValue',
119 | },
120 | ],
121 | },
122 |
123 | // Invalid - importing from lib path containing useResponsiveValue
124 | {
125 | code: `import { useResponsiveValue } from './useResponsiveValue'`,
126 | errors: [
127 | {
128 | messageId: 'noUseResponsiveValue',
129 | },
130 | ],
131 | },
132 | ],
133 | })
134 |
--------------------------------------------------------------------------------
/src/rules/__tests__/spread-props-first.test.js:
--------------------------------------------------------------------------------
1 | const rule = require('../spread-props-first')
2 | const {RuleTester} = require('eslint')
3 |
4 | const ruleTester = new RuleTester({
5 | languageOptions: {
6 | ecmaVersion: 'latest',
7 | sourceType: 'module',
8 | parserOptions: {
9 | ecmaFeatures: {
10 | jsx: true,
11 | },
12 | },
13 | },
14 | })
15 |
16 | ruleTester.run('spread-props-first', rule, {
17 | valid: [
18 | // Spread props before named props
19 | ``,
20 | // Multiple spreads before named props
21 | ``,
22 | // Only spread props
23 | ``,
24 | // Only named props
25 | ``,
26 | // Empty element
27 | ``,
28 | // Spread first, then named props
29 | ``,
30 | // Multiple spreads at the beginning
31 | ``,
32 | ],
33 | invalid: [
34 | // Named prop before spread
35 | {
36 | code: ``,
37 | output: ``,
38 | errors: [
39 | {
40 | messageId: 'spreadPropsFirst',
41 | data: {spreadProp: '{...rest}', namedProp: 'className'},
42 | },
43 | ],
44 | },
45 | // Multiple named props before spread
46 | {
47 | code: ``,
48 | output: ``,
49 | errors: [
50 | {
51 | messageId: 'spreadPropsFirst',
52 | data: {spreadProp: '{...rest}', namedProp: 'id'},
53 | },
54 | ],
55 | },
56 | // Named prop with expression before spread
57 | {
58 | code: ``,
59 | output: ``,
60 | errors: [
61 | {
62 | messageId: 'spreadPropsFirst',
63 | data: {spreadProp: '{...rest}', namedProp: 'onClick'},
64 | },
65 | ],
66 | },
67 | // Mixed order with multiple spreads
68 | {
69 | code: ``,
70 | output: ``,
71 | errors: [
72 | {
73 | messageId: 'spreadPropsFirst',
74 | data: {spreadProp: '{...rest}', namedProp: 'id'},
75 | },
76 | ],
77 | },
78 | // Named prop before multiple spreads
79 | {
80 | code: ``,
81 | output: ``,
82 | errors: [
83 | {
84 | messageId: 'spreadPropsFirst',
85 | data: {spreadProp: '{...rest}', namedProp: 'className'},
86 | },
87 | ],
88 | },
89 | // Complex example with many props
90 | {
91 | code: ``,
92 | output: ``,
93 | errors: [
94 | {
95 | messageId: 'spreadPropsFirst',
96 | data: {spreadProp: '{...rest}', namedProp: 'disabled'},
97 | },
98 | ],
99 | },
100 | // Boolean prop before spread
101 | {
102 | code: ``,
103 | output: ``,
104 | errors: [
105 | {
106 | messageId: 'spreadPropsFirst',
107 | data: {spreadProp: '{...rest}', namedProp: 'disabled'},
108 | },
109 | ],
110 | },
111 | // Spread in the middle
112 | {
113 | code: ``,
114 | output: ``,
115 | errors: [
116 | {
117 | messageId: 'spreadPropsFirst',
118 | data: {spreadProp: '{...rest}', namedProp: 'id'},
119 | },
120 | ],
121 | },
122 | ],
123 | })
124 |
--------------------------------------------------------------------------------
/src/rules/__tests__/a11y-link-in-text-block.test.js:
--------------------------------------------------------------------------------
1 | const rule = require('../a11y-link-in-text-block')
2 | const {RuleTester} = require('eslint')
3 |
4 | const ruleTester = new RuleTester({
5 | languageOptions: {
6 | ecmaVersion: 'latest',
7 | sourceType: 'module',
8 | parserOptions: {
9 | ecmaFeatures: {
10 | jsx: true,
11 | },
12 | },
13 | },
14 | })
15 |
16 | ruleTester.run('a11y-link-in-text-block', rule, {
17 | valid: [
18 | `import {Link} from '@primer/react';
19 |
20 |
21 |
22 | Blah blah
23 | {' '}
24 | .
25 |
26 | `,
27 | `import {Text, Link} from '@primer/react';
28 |
29 |
30 | blah
31 |
32 |
33 | `,
34 | `import {Link} from '@primer/react';
35 | bla blah Link level 1
;
36 | `,
37 | `import {Link} from '@primer/react';
38 | bla blahLink level 1
;
39 | `,
40 | `import {Link} from '@primer/react';
41 | <>somethingLink level 1>;
42 | `,
43 | `import {Link} from '@primer/react';
44 | Link level 1;
45 | `,
46 | `import {Heading, Link} from '@primer/react';
47 |
48 | Link level 1
49 | hello
50 |
51 | `,
52 | `import {Heading, Link} from '@primer/react';
53 |
54 |
55 | Breadcrumb
56 |
57 | Create a thing
58 |
59 | `,
60 | `import {Link} from '@primer/react';
61 |
62 |
63 |
64 | Breadcrumb
65 |
66 |
67 | Create a thing
68 |
69 | `,
70 | `import {Link} from '@primer/react';
71 |
72 |
73 | {owner}
74 | {' '}
75 | last edited{' '}
76 |
77 | `,
78 | `import {Link} from '@primer/react';
79 |
80 | by
81 |
82 | Blah blah
83 |
84 |
85 | `,
86 | `import {Link} from '@primer/react';
87 |
88 | by
89 |
90 | Blah blah
91 |
92 |
93 | `,
94 | `import {Link} from '@primer/react';
95 |
96 | by
97 |
98 | Blah blah
99 |
100 |
101 | `,
102 | `import {Link} from '@primer/react';
103 |
104 |
105 |
106 | Blah blah
107 | {' '}
108 | .
109 |
110 | `,
111 | `import {Link} from '@primer/react';
112 |
113 | In addition,{' '}
114 |
115 | GitHub Team
116 | {' '}
117 | includes:
118 |
119 | `,
120 | `import {Link} from '@primer/react';
121 | bla blah
122 | Link text
123 |
124 | `,
125 | `import {Link} from '@primer/react';
126 | bla blah
127 | Link text
128 |
129 | `,
130 | ],
131 | invalid: [
132 | {
133 | code: `import {Link} from '@primer/react';
134 | bla blahLink level 1
135 | `,
136 | errors: [{messageId: 'linkInTextBlock'}],
137 | },
138 | {
139 | code: `import {Link} from '@primer/react';
140 | Link level 1 something something
141 | `,
142 | errors: [{messageId: 'linkInTextBlock'}],
143 | },
144 | {
145 | code: `import {Link} from '@primer/react';
146 | bla blahLink level 1
147 | `,
148 | errors: [{messageId: 'linkInTextBlock'}],
149 | },
150 | {
151 | code: `import {Link} from '@primer/react';
152 | Something something{' '}
153 | Link level 1
154 |
155 | `,
156 | errors: [{messageId: 'linkInTextBlock'}],
157 | },
158 | {
159 | code: `import {Link} from '@primer/react';
160 | <>blah blah blah{' '}
161 | Link level 1>;
162 | `,
163 | errors: [{messageId: 'linkInTextBlock'}],
164 | },
165 | {
166 | code: `import {Link} from '@primer/react';
167 | <>blah blah blah{' '}
168 | Link level 1>;
169 | `,
170 | errors: [{messageId: 'linkInTextBlock'}],
171 | },
172 | ],
173 | })
174 |
--------------------------------------------------------------------------------
/src/rules/__tests__/no-deprecated-props.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const {RuleTester} = require('eslint')
4 | const rule = require('../no-deprecated-props')
5 |
6 | const ruleTester = new RuleTester({
7 | languageOptions: {
8 | ecmaVersion: 'latest',
9 | sourceType: 'module',
10 | parserOptions: {
11 | ecmaFeatures: {
12 | jsx: true,
13 | },
14 | },
15 | },
16 | })
17 |
18 | ruleTester.run('no-deprecated-props', rule, {
19 | valid: [
20 | `import {ActionList} from '@primer/react';
21 |
22 |
23 | Group heading 1
24 | Item
25 |
26 |
27 | Group heading 2
28 | Item 2
29 |
30 | `,
31 | `import {ActionList} from '@primer/react';
32 |
33 |
34 | Group heading 1
35 | Item
36 |
37 |
38 | Group heading 2
39 | Item 2
40 |
41 | `,
42 | `import {ActionList} from '@primer/react';
43 |
44 |
45 | Group heading
46 | Item
47 |
48 | Item 2
49 | `,
50 | `import {ActionList} from '@primer/react';
51 |
52 |
53 | Group heading
54 | Item
55 |
56 | Item 2
57 | `,
58 | `import {ActionList} from '@primer/react';
59 |
60 | Item
61 |
62 | Group heading
63 | Group item
64 |
65 | `,
66 | ],
67 | invalid: [
68 | {
69 | code: ``,
70 | output: `Group heading 1`,
71 | errors: [
72 | {
73 | messageId: 'titlePropDeprecated',
74 | },
75 | ],
76 | },
77 | {
78 | code: ``,
79 | output: `Group heading 1`,
80 | errors: [
81 | {
82 | messageId: 'titlePropDeprecated',
83 | },
84 | ],
85 | },
86 | {
87 | code: ``,
88 | output: `Group heading 1`,
89 | errors: [
90 | {
91 | messageId: 'titlePropDeprecated',
92 | },
93 | ],
94 | },
95 | {
96 | code: ``,
97 | output: `{titleVariable}`,
98 | errors: [
99 | {
100 | messageId: 'titlePropDeprecated',
101 | },
102 | ],
103 | },
104 | {
105 | code: ``,
106 | output: `{'Title'}`,
107 | errors: [
108 | {
109 | messageId: 'titlePropDeprecated',
110 | },
111 | ],
112 | },
113 | {
114 | code: ``,
115 | output: `{condition ? 'Title' : undefined}`,
116 | errors: [
117 | {
118 | messageId: 'titlePropDeprecated',
119 | },
120 | ],
121 | },
122 | ],
123 | })
124 |
--------------------------------------------------------------------------------
/src/rules/use-deprecated-from-deprecated.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const url = require('../url')
4 |
5 | const components = [
6 | {
7 | identifier: 'Dialog',
8 | entrypoint: '@primer/react',
9 | },
10 | {
11 | identifier: 'DialogProps',
12 | entrypoint: '@primer/react',
13 | },
14 | {
15 | identifier: 'DialogHeaderProps',
16 | entrypoint: '@primer/react',
17 | },
18 | {
19 | identifier: 'Octicon',
20 | entrypoint: '@primer/react',
21 | },
22 | {
23 | identifier: 'OcticonProps',
24 | entrypoint: '@primer/react',
25 | },
26 | {
27 | identifier: 'Pagehead',
28 | entrypoint: '@primer/react',
29 | },
30 | {
31 | identifier: 'PageheadProps',
32 | entrypoint: '@primer/react',
33 | },
34 | {
35 | identifier: 'TabNav',
36 | entrypoint: '@primer/react',
37 | },
38 | {
39 | identifier: 'TabNavProps',
40 | entrypoint: '@primer/react',
41 | },
42 | {
43 | identifier: 'TabNavLinkProps',
44 | entrypoint: '@primer/react',
45 | },
46 | {
47 | identifier: 'Tooltip',
48 | entrypoint: '@primer/react',
49 | },
50 | {
51 | identifier: 'TooltipProps',
52 | entrypoint: '@primer/react',
53 | },
54 | ]
55 |
56 | const entrypoints = new Map()
57 |
58 | for (const component of components) {
59 | if (!entrypoints.has(component.entrypoint)) {
60 | entrypoints.set(component.entrypoint, new Set())
61 | }
62 | entrypoints.get(component.entrypoint).add(component.identifier)
63 | }
64 |
65 | /**
66 | * @type {import('eslint').Rule.RuleModule}
67 | */
68 | module.exports = {
69 | meta: {
70 | type: 'problem',
71 | docs: {
72 | description: 'Use deprecated components from the `@primer/react/deprecated` entrypoint',
73 | recommended: true,
74 | url: url(module),
75 | },
76 | fixable: true,
77 | schema: [],
78 | },
79 | create(context) {
80 | const sourceCode = context.getSourceCode()
81 |
82 | return {
83 | ImportDeclaration(node) {
84 | if (!entrypoints.has(node.source.value)) {
85 | return
86 | }
87 |
88 | const entrypoint = entrypoints.get(node.source.value)
89 | const deprecated = node.specifiers.filter(specifier => {
90 | return entrypoint.has(specifier.imported.name)
91 | })
92 |
93 | if (deprecated.length === 0) {
94 | return
95 | }
96 |
97 | const deprecatedEntrypoint = node.parent.body.find(node => {
98 | if (node.type !== 'ImportDeclaration') {
99 | return false
100 | }
101 |
102 | return node.source.value === '@primer/react/deprecated'
103 | })
104 |
105 | // All imports are deprecated
106 | if (deprecated.length === node.specifiers.length) {
107 | context.report({
108 | node,
109 | message: 'Import deprecated components from @primer/react/deprecated',
110 | *fix(fixer) {
111 | if (deprecatedEntrypoint) {
112 | const lastSpecifier = deprecatedEntrypoint.specifiers[deprecatedEntrypoint.specifiers.length - 1]
113 |
114 | yield fixer.remove(node)
115 | yield fixer.insertTextAfter(
116 | lastSpecifier,
117 | `, ${node.specifiers.map(specifier => specifier.imported.name).join(', ')}`,
118 | )
119 | } else {
120 | yield fixer.replaceText(node.source, `'@primer/react/deprecated'`)
121 | }
122 | },
123 | })
124 | } else {
125 | // There is a mix of deprecated and non-deprecated imports
126 | context.report({
127 | node,
128 | message: 'Import deprecated components from @primer/react/deprecated',
129 | *fix(fixer) {
130 | for (const specifier of deprecated) {
131 | yield fixer.remove(specifier)
132 | const comma = sourceCode.getTokenAfter(specifier)
133 | if (comma.value === ',') {
134 | yield fixer.remove(comma)
135 | }
136 | }
137 |
138 | if (deprecatedEntrypoint) {
139 | const lastSpecifier = deprecatedEntrypoint.specifiers[deprecatedEntrypoint.specifiers.length - 1]
140 | yield fixer.insertTextAfter(
141 | lastSpecifier,
142 | `, ${deprecated.map(specifier => specifier.imported.name).join(', ')}`,
143 | )
144 | } else {
145 | yield fixer.insertTextAfter(
146 | node,
147 | `\nimport {${deprecated
148 | .map(specifier => specifier.imported.name)
149 | .join(', ')}} from '@primer/react/deprecated'`,
150 | )
151 | }
152 | },
153 | })
154 | }
155 | },
156 | }
157 | },
158 | }
159 |
--------------------------------------------------------------------------------
/src/rules/new-color-css-vars.js:
--------------------------------------------------------------------------------
1 | const cssVars = require('../utils/css-variable-map.json')
2 |
3 | const reportError = (propertyName, valueNode, context, suggestFix = true) => {
4 | // performance optimisation: exit early
5 | if (valueNode.type !== 'Literal' && valueNode.type !== 'TemplateElement') return
6 | // get property value
7 | const value = valueNode.type === 'Literal' ? valueNode.value : valueNode.value.cooked
8 | // return if value is not a string
9 | if (typeof value !== 'string') return
10 | // return if value does not include variable
11 | if (!value.includes('var(')) return
12 |
13 | const varRegex = /var\([^)]+\)/g
14 |
15 | const match = value.match(varRegex)
16 | if (!match) return
17 | const vars = match.flatMap(match =>
18 | match
19 | .slice(4, -1)
20 | .trim()
21 | .split(/\s*,\s*/g),
22 | )
23 |
24 | for (const cssVar of vars) {
25 | // get the array of objects for the variable name (e.g. --color-fg-primary)
26 | const cssVarObjects = cssVars[cssVar]
27 | // get the object that contains the property name or the first one (default)
28 | const varObjectForProp = propertyName
29 | ? cssVarObjects?.find(prop => prop.props.includes(propertyName))
30 | : cssVarObjects?.[0]
31 | // return if no replacement exists
32 | if (!varObjectForProp?.replacement) return
33 | // report the error
34 | context.report({
35 | node: valueNode,
36 | message: `Replace var(${cssVar}) with var(${varObjectForProp.replacement})`,
37 | fix: suggestFix
38 | ? fixer => {
39 | const fixedString = value.replaceAll(cssVar, `${varObjectForProp.replacement}`)
40 | return fixer.replaceText(valueNode, valueNode.type === 'Literal' ? `'${fixedString}'` : fixedString)
41 | }
42 | : undefined,
43 | })
44 | }
45 | }
46 |
47 | const reportOnObject = (node, context) => {
48 | const propertyName = node.key.name
49 | if (node.value?.type === 'Literal') {
50 | reportError(propertyName, node.value, context)
51 | } else if (node.value?.type === 'ConditionalExpression') {
52 | reportError(propertyName, node.value.consequent, context)
53 | reportError(propertyName, node.value.alternate, context)
54 | }
55 | }
56 |
57 | const reportOnProperty = (node, context) => {
58 | const propertyName = node.name.name
59 | if (node.value?.type === 'Literal') {
60 | reportError(propertyName, node.value, context)
61 | } else if (node.value?.type === 'JSXExpressionContainer' && node.value.expression?.type === 'ConditionalExpression') {
62 | reportError(propertyName, node.value.expression.consequent, context)
63 | reportError(propertyName, node.value.expression.alternate, context)
64 | }
65 | }
66 |
67 | const reportOnValue = (node, context) => {
68 | if (node?.type === 'Literal') {
69 | reportError(undefined, node, context)
70 | } else if (node?.type === 'JSXExpressionContainer' && node.expression?.type === 'ConditionalExpression') {
71 | reportError(undefined, node.value.expression.consequent, context)
72 | reportError(undefined, node.value.expression.alternate, context)
73 | }
74 | }
75 |
76 | const reportOnTemplateElement = (node, context) => {
77 | reportError(undefined, node, context, false)
78 | }
79 |
80 | module.exports = {
81 | meta: {
82 | type: 'suggestion',
83 | hasSuggestions: true,
84 | fixable: 'code',
85 | docs: {
86 | description: 'Upgrade legacy CSS variables to Primitives v8 in sx prop',
87 | },
88 | schema: [
89 | {
90 | type: 'object',
91 | properties: {
92 | skipImportCheck: {
93 | type: 'boolean',
94 | },
95 | checkAllStrings: {
96 | type: 'boolean',
97 | },
98 | },
99 | additionalProperties: false,
100 | },
101 | ],
102 | },
103 | /** @param {import('eslint').Rule.RuleContext} context */
104 | create(context) {
105 | return {
106 | // sx OR style property on elements
107 | ['JSXAttribute:matches([name.name=sx], [name.name=style]) ObjectExpression Property']: node =>
108 | reportOnObject(node, context),
109 | // variable that is an object
110 | [':matches(VariableDeclarator, ReturnStatement, ConditionalExpression, ArrowFunctionExpression, CallExpression) > ObjectExpression Property, :matches(VariableDeclarator, ReturnStatement, ConditionalExpression, ArrowFunctionExpression, CallExpression) > ObjectExpression Property > ObjectExpression Property']:
111 | node => reportOnObject(node, context),
112 | // property on element like stroke or fill
113 | ['JSXAttribute[name.name!=sx][name.name!=style]']: node => reportOnProperty(node, context),
114 | // variable that is a value
115 | [':matches(VariableDeclarator, ReturnStatement) > Literal']: node => reportOnValue(node, context),
116 | // variable that is a value
117 | ['VariableDeclarator TemplateElement']: node => reportOnTemplateElement(node, context),
118 | }
119 | },
120 | }
121 |
--------------------------------------------------------------------------------
/src/rules/a11y-link-in-text-block.js:
--------------------------------------------------------------------------------
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 | module.exports = {
6 | meta: {
7 | docs: {
8 | url: require('../url')(module),
9 | },
10 | type: 'problem',
11 | schema: [
12 | {
13 | properties: {
14 | skipImportCheck: {
15 | type: 'boolean',
16 | },
17 | },
18 | },
19 | ],
20 | messages: {
21 | linkInTextBlock:
22 | 'Links should have the inline prop if it appear in a text block and only uses color to distinguish itself from surrounding text.',
23 | },
24 | },
25 | create(context) {
26 | const sourceCode = context.sourceCode ?? context.getSourceCode()
27 | return {
28 | JSXElement(node) {
29 | const name = getJSXOpeningElementName(node.openingElement)
30 | if (
31 | isPrimerComponent(node.openingElement.name, sourceCode.getScope(node)) &&
32 | name === 'Link' &&
33 | node.parent.children
34 | ) {
35 | // Skip if Link has className because we cannot deduce what styles are applied.
36 | const classNameAttribute = getJSXOpeningElementAttribute(node.openingElement, 'className')
37 | if (classNameAttribute) return
38 |
39 | let siblings = node.parent.children
40 | const parentName = node.parent.openingElement?.name?.name
41 | // Skip if Link is nested inside of a heading.
42 | const parentsToSkip = ['Heading', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
43 | if (parentsToSkip.includes(parentName)) return
44 | if (siblings.length > 0) {
45 | siblings = siblings.filter(childNode => {
46 | return (
47 | !(childNode.type === 'JSXText' && /^\s+$/.test(childNode.value)) &&
48 | !(
49 | childNode.type === 'JSXExpressionContainer' &&
50 | childNode.expression.type === 'Literal' &&
51 | /^\s+$/.test(childNode.expression.value)
52 | ) &&
53 | !(childNode.type === 'Literal' && /^\s+$/.test(childNode.value))
54 | )
55 | })
56 | const index = siblings.findIndex(childNode => {
57 | return childNode.range === node.range
58 | })
59 | const prevSibling = siblings[index - 1]
60 | const nextSibling = siblings[index + 1]
61 |
62 | const prevSiblingIsText = prevSibling && prevSibling.type === 'JSXText'
63 | const nextSiblingIsText = nextSibling && nextSibling.type === 'JSXText'
64 | if (prevSiblingIsText || nextSiblingIsText) {
65 | // Skip if the only text adjacent to the link is a period, then skip it.
66 | if (!prevSiblingIsText && /^\s*\.+\s*$/.test(nextSibling.value)) {
67 | return
68 | }
69 | const sxAttribute = getJSXOpeningElementAttribute(node.openingElement, 'sx')
70 | const inlineAttribute = getJSXOpeningElementAttribute(node.openingElement, 'inline')
71 |
72 | // Skip if Link child is a JSX element.
73 | const jsxElementChildren = node.children.filter(child => {
74 | return child.type === 'JSXElement'
75 | })
76 | if (jsxElementChildren.length > 0) return
77 |
78 | // Skip if fontWeight or fontFamily is set via the sx prop since these may technically be considered sufficiently distinguishing styles that don't use color.
79 | if (
80 | sxAttribute &&
81 | sxAttribute?.value?.expression &&
82 | sxAttribute.value.expression.type === 'ObjectExpression' &&
83 | sxAttribute.value.expression.properties &&
84 | sxAttribute.value.expression.properties.length > 0
85 | ) {
86 | const fontStyleProperty = sxAttribute.value.expression.properties.filter(property => {
87 | return property.key.name === 'fontWeight' || property.key.name === 'fontFamily'
88 | })
89 | if (fontStyleProperty.length > 0) return
90 | }
91 | if (inlineAttribute) {
92 | if (!inlineAttribute.value) {
93 | return
94 | } else if (inlineAttribute.value.type === 'JSXExpressionContainer') {
95 | if (inlineAttribute.value.expression.type === 'Literal') {
96 | if (inlineAttribute.value.expression.value === true) {
97 | return
98 | }
99 | }
100 | }
101 | }
102 | context.report({
103 | node,
104 | messageId: 'linkInTextBlock',
105 | })
106 | }
107 | }
108 | }
109 | },
110 | }
111 | },
112 | }
113 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 |
6 | concurrency:
7 | group: ${{ github.workflow }}-${{ github.ref }}
8 | cancel-in-progress: true
9 |
10 | permissions:
11 | id-token: write # Required for OIDC
12 | contents: read
13 | checks: write
14 | statuses: write
15 |
16 | jobs:
17 | release-main:
18 | if: ${{ github.ref_name == 'main' }}
19 | name: Main
20 | runs-on: ubuntu-latest
21 | steps:
22 | - name: Checkout repository
23 | uses: actions/checkout@v6
24 | with:
25 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
26 | fetch-depth: 0
27 | persist-credentials: false
28 |
29 | - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42
30 | id: app-token
31 | with:
32 | app-id: ${{ vars.PRIMER_APP_ID_SHARED }}
33 | private-key: ${{ secrets.PRIMER_APP_PRIVATE_KEY_SHARED }}
34 |
35 | - name: Set up Node.js
36 | uses: actions/setup-node@v6
37 | with:
38 | node-version: 24
39 |
40 | - name: Install dependencies
41 | run: npm ci
42 |
43 | - name: Create release pull request or publish to npm
44 | id: changesets
45 | uses: changesets/action@v1
46 | with:
47 | title: Release Tracking
48 | # This expects you to have a script called release which does a build for your packages and calls changeset publish
49 | publish: npm run release
50 | env:
51 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
52 |
53 | release-canary:
54 | name: Canary
55 | if: ${{ github.repository == 'primer/eslint-plugin-primer-react' && github.ref_name != 'main' && github.ref_name != 'changeset-release/main' }}
56 | runs-on: ubuntu-latest
57 | steps:
58 | - name: Checkout repository
59 | uses: actions/checkout@v6
60 | with:
61 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
62 | fetch-depth: 0
63 |
64 | - name: Set up Node.js
65 | uses: actions/setup-node@v6
66 | with:
67 | node-version: 24
68 |
69 | - name: Install dependencies
70 | run: npm ci
71 |
72 | - name: Build
73 | run: npm run build --if-present
74 |
75 | - name: Publish canary version
76 | run: |
77 | echo "$( jq '.version = "0.0.0"' package.json )" > package.json
78 | echo -e "---\n'eslint-plugin-primer-react': patch\n---\n\nFake entry to force publishing" > .changeset/force-snapshot-release.md
79 | npx changeset version --snapshot
80 | npx changeset publish --tag canary
81 | env:
82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
83 |
84 | - name: Output canary version number
85 | uses: actions/github-script@v7.0.1
86 | with:
87 | script: |
88 | const package = require(`${process.env.GITHUB_WORKSPACE}/package.json`)
89 | github.rest.repos.createCommitStatus({
90 | owner: context.repo.owner,
91 | repo: context.repo.repo,
92 | sha: context.sha,
93 | state: 'success',
94 | context: `Published ${package.name}`,
95 | description: package.version,
96 | target_url: `https://unpkg.com/${package.name}@${package.version}/`
97 | })
98 |
99 | - name: Upload versions json file
100 | uses: primer/.github/.github/actions/upload-versions@main
101 |
102 | release-candidate:
103 | name: Candidate
104 | if: ${{ github.repository == 'primer/eslint-plugin-primer-react' && github.ref_name == 'changeset-release/main' }}
105 |
106 | runs-on: ubuntu-latest
107 | steps:
108 | - name: Checkout repository
109 | uses: actions/checkout@v6
110 | with:
111 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
112 | fetch-depth: 0
113 |
114 | - name: Set up Node.js
115 | uses: actions/setup-node@v6
116 | with:
117 | node-version: 24
118 |
119 | - name: Install dependencies
120 | run: npm ci
121 |
122 | - name: Build
123 | run: npm run build --if-present
124 |
125 | - name: Publish release candidate
126 | run: |
127 | version=$(jq -r .version package.json)
128 | echo "$( jq ".version = \"$(echo $version)-rc.$(git rev-parse --short HEAD)\"" package.json )" > package.json
129 | npm publish --tag next
130 | env:
131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
132 |
133 | - name: Output release candidate version number
134 | uses: actions/github-script@v7.0.1
135 | with:
136 | script: |
137 | const package = require(`${process.env.GITHUB_WORKSPACE}/package.json`)
138 | github.rest.repos.createCommitStatus({
139 | owner: context.repo.owner,
140 | repo: context.repo.repo,
141 | sha: context.sha,
142 | state: 'success',
143 | context: `Published ${package.name}`,
144 | description: package.version,
145 | target_url: `https://unpkg.com/${package.name}@${package.version}/`
146 | })
147 |
148 | - name: Upload versions json file
149 | uses: primer/.github/.github/actions/upload-versions@main
150 |
--------------------------------------------------------------------------------
/docs/rules/use-styled-react-import.md:
--------------------------------------------------------------------------------
1 | # use-styled-react-import
2 |
3 | 💼 This rule is _disabled_ in the ✅ `recommended` config.
4 |
5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6 |
7 |
8 |
9 | Enforce importing components that use `sx` prop from `@primer/styled-react` instead of `@primer/react`, and vice versa.
10 |
11 | ## Rule Details
12 |
13 | This rule detects when certain Primer React components are used with the `sx` prop and ensures they are imported from the temporary `@primer/styled-react` package instead of `@primer/react`. When the same components are used without the `sx` prop, it ensures they are imported from `@primer/react` instead of `@primer/styled-react`.
14 |
15 | When a component is used with the `sx` prop anywhere in the file, the entire component import is moved to `@primer/styled-react`, simplifying the import structure.
16 |
17 | It also moves certain types and utilities to the styled-react package.
18 |
19 | ### Components that should be imported from `@primer/styled-react` when used with `sx`:
20 |
21 | - ActionList
22 | - ActionMenu
23 | - Box
24 | - Breadcrumbs
25 | - Button
26 | - Flash
27 | - FormControl
28 | - Heading
29 | - IconButton
30 | - Label
31 | - Link
32 | - LinkButton
33 | - PageLayout
34 | - Text
35 | - TextInput
36 | - Truncate
37 | - Octicon
38 | - Dialog
39 |
40 | ### Types and utilities that should always be imported from `@primer/styled-react`:
41 |
42 | - `BoxProps` (type)
43 | - `SxProp` (type)
44 | - `BetterSystemStyleObject` (type)
45 | - `sx` (utility)
46 |
47 | ## Examples
48 |
49 | ### ❌ Incorrect
50 |
51 | ```jsx
52 | import {Button, Link} from '@primer/react'
53 |
54 | const Component = () =>
55 | ```
56 |
57 | ```jsx
58 | import {Box} from '@primer/react'
59 |
60 | const Component = () => Content
61 | ```
62 |
63 | ```jsx
64 | import {sx} from '@primer/react'
65 | ```
66 |
67 | ```jsx
68 | import {Button} from '@primer/styled-react'
69 |
70 | const Component = () =>
71 | ```
72 |
73 | ```jsx
74 | import {Button} from '@primer/react'
75 |
76 | const Component1 = () =>
77 | const Component2 = () =>
78 | ```
79 |
80 | ### ✅ Correct
81 |
82 | ```jsx
83 | import {Link} from '@primer/react'
84 | import {Button} from '@primer/styled-react'
85 |
86 | const Component = () =>
87 | ```
88 |
89 | ```jsx
90 | import {Box} from '@primer/styled-react'
91 |
92 | const Component = () => Content
93 | ```
94 |
95 | ```jsx
96 | import {sx} from '@primer/styled-react'
97 | ```
98 |
99 | ```jsx
100 | // Components without sx prop can stay in @primer/react
101 | import {Button} from '@primer/react'
102 |
103 | const Component = () =>
104 | ```
105 |
106 | ```jsx
107 | // Components imported from styled-react but used without sx prop should be moved back
108 | import {Button} from '@primer/react'
109 |
110 | const Component = () =>
111 | ```
112 |
113 | ```jsx
114 | // When a component is used with sx prop anywhere, import from styled-react
115 | import {Button} from '@primer/styled-react'
116 |
117 | const Component1 = () =>
118 | const Component2 = () =>
119 | ```
120 |
121 | ## Options
122 |
123 | This rule accepts an optional configuration object with the following properties:
124 |
125 | - `styledComponents` (array of strings): Components that should be imported from `@primer/styled-react` when used with `sx` prop. Defaults to the list shown above.
126 | - `styledTypes` (array of strings): Types that should always be imported from `@primer/styled-react`. Defaults to `['BoxProps', 'SxProp', 'BetterSystemStyleObject']`.
127 | - `styledUtilities` (array of strings): Utilities that should always be imported from `@primer/styled-react`. Defaults to `['sx']`.
128 |
129 | ### Example Configuration
130 |
131 | ```json
132 | {
133 | "rules": {
134 | "@primer/primer-react/use-styled-react-import": [
135 | "error",
136 | {
137 | "styledComponents": ["Button", "Box", "CustomComponent"],
138 | "styledTypes": ["BoxProps", "CustomProps"],
139 | "styledUtilities": ["sx", "customSx"]
140 | }
141 | ]
142 | }
143 | }
144 | ```
145 |
146 | ### Configuration Examples
147 |
148 | #### ❌ Incorrect with custom configuration
149 |
150 | ```jsx
151 | // With styledComponents: ["CustomButton"]
152 | import {CustomButton} from '@primer/react'
153 |
154 | const Component = () => Click me
155 | ```
156 |
157 | #### ✅ Correct with custom configuration
158 |
159 | ```jsx
160 | // With styledComponents: ["CustomButton"]
161 | import {CustomButton} from '@primer/styled-react'
162 |
163 | const Component = () => Click me
164 | ```
165 |
166 | ```jsx
167 | // Box is not in custom styledComponents list, so it can be used with sx from @primer/react
168 | import {Box} from '@primer/react'
169 |
170 | const Component = () => Content
171 | ```
172 |
173 | ## When Not To Use It
174 |
175 | This rule is specifically for migrating components that use the `sx` prop to the temporary `@primer/styled-react` package. If you're not using the `sx` prop or not participating in this migration, you can disable this rule.
176 |
--------------------------------------------------------------------------------
/src/rules/__tests__/direct-slot-children.test.js:
--------------------------------------------------------------------------------
1 | const rule = require('../direct-slot-children')
2 | const {RuleTester} = require('eslint')
3 |
4 | const ruleTester = new RuleTester({
5 | languageOptions: {
6 | ecmaVersion: 'latest',
7 | sourceType: 'module',
8 | parserOptions: {
9 | ecmaFeatures: {
10 | jsx: true,
11 | },
12 | },
13 | },
14 | })
15 |
16 | ruleTester.run('direct-slot-children', rule, {
17 | valid: [
18 | `import {PageLayout} from '@primer/react'; HeaderFooter`,
19 | `import {PageLayout} from '@primer/react'; `,
20 | `import {PageLayout} from '@primer/react'; {true ? Header : null}`,
21 | `import {PageLayout} from './PageLayout'; Header`,
22 | `import {FormControl, Radio} from '@primer/react'; Choice one`,
23 | `import {ActionList} from '@primer/react';
24 | monaMonalisa Octocat`,
25 | `import {ActionList} from '@primer/react';
26 | monaMonalisa Octocat`,
27 | `import {MarkdownEditor} from '@primer/react'; `,
28 | `import {MarkdownEditor} from '@primer/react'; `,
29 | {code: `import {Foo} from './Foo';
`, options: [{skipImportCheck: true}]},
30 | ],
31 | invalid: [
32 | {
33 | code: `import {PageLayout} from '@primer/react'; Header`,
34 | errors: [
35 | {
36 | messageId: 'directSlotChildren',
37 | data: {childName: 'PageLayout.Header', parentName: 'PageLayout'},
38 | },
39 | ],
40 | },
41 | {
42 | code: `import {PageLayout} from '@primer/react'; function Header() { return Header; }`,
43 | errors: [
44 | {
45 | messageId: 'directSlotChildren',
46 | data: {childName: 'PageLayout.Header', parentName: 'PageLayout'},
47 | },
48 | ],
49 | },
50 | {
51 | code: `import {PageLayout} from '@primer/react/drafts'; Header`,
52 | errors: [
53 | {
54 | messageId: 'directSlotChildren',
55 | data: {childName: 'PageLayout.Header', parentName: 'PageLayout'},
56 | },
57 | ],
58 | },
59 | {
60 | code: `import {PageLayout} from '@primer/react'; `,
61 | errors: [
62 | {
63 | messageId: 'directSlotChildren',
64 | data: {childName: 'PageLayout.Header', parentName: 'PageLayout'},
65 | },
66 | ],
67 | },
68 | {
69 | code: `import {PageLayout} from '@primer/react'; `,
70 | errors: [
71 | {
72 | messageId: 'directSlotChildren',
73 | data: {childName: 'PageLayout.Header', parentName: 'PageLayout'},
74 | },
75 | ],
76 | },
77 | {
78 | code: `import {TreeView} from '@primer/react'; Visual
`,
79 | errors: [
80 | {
81 | messageId: 'directSlotChildren',
82 | data: {childName: 'TreeView.LeadingVisual', parentName: 'TreeView.Item'},
83 | },
84 | ],
85 | },
86 | {
87 | code: `import {PageLayout} from './PageLayout'; `,
88 | options: [{skipImportCheck: true}],
89 | errors: [
90 | {
91 | messageId: 'directSlotChildren',
92 | data: {childName: 'PageLayout.Header', parentName: 'PageLayout'},
93 | },
94 | ],
95 | },
96 | {
97 | code: `import {ActionList} from '@primer/react'; `,
98 | errors: [
99 | {
100 | messageId: 'directSlotChildren',
101 | data: {childName: 'ActionList.LeadingVisual', parentName: 'ActionList.Item or ActionList.LinkItem'},
102 | },
103 | ],
104 | },
105 | {
106 | code: `import {MarkdownEditor} from '@primer/react';
`,
107 | errors: [
108 | {
109 | messageId: 'directSlotChildren',
110 | data: {childName: 'MarkdownEditor.Actions', parentName: 'MarkdownEditor or MarkdownEditor.Footer'},
111 | },
112 | ],
113 | },
114 | {
115 | code: `import {MarkdownEditor} from '@primer/react'; `,
116 | errors: [
117 | {
118 | messageId: 'directSlotChildren',
119 | data: {childName: 'MarkdownEditor.FooterButton', parentName: 'MarkdownEditor.Footer'},
120 | },
121 | ],
122 | },
123 | ],
124 | })
125 |
--------------------------------------------------------------------------------
/src/rules/a11y-use-accessible-tooltip.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const url = require('../url')
3 | const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
4 | const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
5 |
6 | module.exports = {
7 | meta: {
8 | type: 'suggestion',
9 | docs: {
10 | description: 'recommends the use of @primer/react Tooltip component',
11 | category: 'Best Practices',
12 | recommended: true,
13 | url: url(module),
14 | },
15 | fixable: true,
16 | schema: [],
17 | messages: {
18 | useAccessibleTooltip: 'Please use @primer/react Tooltip component that has accessibility improvements',
19 | useTextProp: 'Please use the text prop instead of aria-label',
20 | noDelayRemoved: 'noDelay prop is removed. Tooltip now has no delay by default',
21 | wrapRemoved: 'wrap prop is removed. Tooltip now wraps by default',
22 | alignRemoved: 'align prop is removed. Please use the direction prop instead.',
23 | },
24 | },
25 | create(context) {
26 | return {
27 | ImportDeclaration(node) {
28 | if (node.source.value !== '@primer/react/deprecated') {
29 | return
30 | }
31 | const hasTooltip = node.specifiers.some(
32 | specifier => specifier.imported && specifier.imported.name === 'Tooltip',
33 | )
34 |
35 | if (!hasTooltip) {
36 | return
37 | }
38 |
39 | const hasOtherImports = node.specifiers.length > 1
40 |
41 | const sourceCode = context.getSourceCode()
42 | // Checking to see if there is an existing root (@primer/react) import
43 | // Assuming there is one root import per file
44 | const rootImport = sourceCode.ast.body.find(statement => {
45 | return statement.type === 'ImportDeclaration' && statement.source.value === '@primer/react'
46 | })
47 |
48 | const tooltipSpecifier = node.specifiers.find(
49 | specifier => specifier.imported && specifier.imported.name === 'Tooltip',
50 | )
51 |
52 | const hasRootImport = rootImport !== undefined
53 |
54 | context.report({
55 | node,
56 | messageId: 'useAccessibleTooltip',
57 | fix(fixer) {
58 | const fixes = []
59 | if (!hasOtherImports) {
60 | // If Tooltip is the only import and no existing @primer/react import, replace the whole import statement
61 | if (!hasRootImport) fixes.push(fixer.replaceText(node.source, `'@primer/react'`))
62 | if (hasRootImport) {
63 | // remove the entire import statement
64 | fixes.push(fixer.remove(node))
65 | // find the last specifier in the existing @primer/react import and insert Tooltip after that
66 | const lastSpecifier = rootImport.specifiers[rootImport.specifiers.length - 1]
67 | fixes.push(fixer.insertTextAfter(lastSpecifier, `, Tooltip`))
68 | }
69 | } else {
70 | // There are other imports from the deprecated bundle but no existing @primer/react import, so remove the Tooltip import and add a new import statement with the correct path.
71 | const previousToken = sourceCode.getTokenBefore(tooltipSpecifier)
72 | const nextToken = sourceCode.getTokenAfter(tooltipSpecifier)
73 | const hasTrailingComma = nextToken && nextToken.value === ','
74 | const hasLeadingComma = previousToken && previousToken.value === ','
75 |
76 | let rangeToRemove
77 |
78 | if (hasTrailingComma) {
79 | rangeToRemove = [tooltipSpecifier.range[0], nextToken.range[1] + 1]
80 | } else if (hasLeadingComma) {
81 | rangeToRemove = [previousToken.range[0], tooltipSpecifier.range[1]]
82 | } else {
83 | rangeToRemove = [tooltipSpecifier.range[0], tooltipSpecifier.range[1]]
84 | }
85 | // Remove Tooltip from the import statement
86 | fixes.push(fixer.removeRange(rangeToRemove))
87 |
88 | if (!hasRootImport) {
89 | fixes.push(fixer.insertTextAfter(node, `\nimport {Tooltip} from '@primer/react';`))
90 | } else {
91 | // find the last specifier in the existing @primer/react import and insert Tooltip after that
92 | const lastSpecifier = rootImport.specifiers[rootImport.specifiers.length - 1]
93 | fixes.push(fixer.insertTextAfter(lastSpecifier, `, Tooltip`))
94 | }
95 | }
96 | return fixes
97 | },
98 | })
99 | },
100 | JSXOpeningElement(node) {
101 | const openingElName = getJSXOpeningElementName(node)
102 | if (openingElName !== 'Tooltip') {
103 | return
104 | }
105 | const ariaLabel = getJSXOpeningElementAttribute(node, 'aria-label')
106 | if (ariaLabel !== undefined) {
107 | context.report({
108 | node,
109 | messageId: 'useTextProp',
110 | fix(fixer) {
111 | return fixer.replaceText(ariaLabel.name, 'text')
112 | },
113 | })
114 | }
115 | const noDelay = getJSXOpeningElementAttribute(node, 'noDelay')
116 | if (noDelay !== undefined) {
117 | context.report({
118 | node,
119 | messageId: 'noDelayRemoved',
120 | fix(fixer) {
121 | return fixer.remove(noDelay)
122 | },
123 | })
124 | }
125 | const wrap = getJSXOpeningElementAttribute(node, 'wrap')
126 | if (wrap !== undefined) {
127 | context.report({
128 | node,
129 | messageId: 'wrapRemoved',
130 | fix(fixer) {
131 | return fixer.remove(wrap)
132 | },
133 | })
134 | }
135 | const align = getJSXOpeningElementAttribute(node, 'align')
136 | if (align !== undefined) {
137 | context.report({
138 | node,
139 | messageId: 'alignRemoved',
140 | fix(fixer) {
141 | return fixer.remove(align)
142 | },
143 | })
144 | }
145 | },
146 | }
147 | },
148 | }
149 |
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | # eslint-plugin-primer-react
2 |
3 | ESLint plugin for Primer React components. This is a JavaScript-based ESLint plugin that provides rules for validating and auto-fixing Primer React component usage.
4 |
5 | **Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.**
6 |
7 | ## Working Effectively
8 |
9 | ### Bootstrap and Setup
10 |
11 | - Install Node.js v20+ (v20 is the current standard):
12 | - Check version: `node --version && npm --version`
13 | - Install dependencies: `npm ci` -- takes 60 seconds. Set timeout to 90+ seconds.
14 | - **NO BUILD STEP REQUIRED** - This is a direct JavaScript project with main entry at `src/index.js`
15 |
16 | ### Development Commands
17 |
18 | - Run tests: `npm test` -- takes 5 seconds. Fast, no long timeout needed.
19 | - Run linting: `npm run lint` -- takes 1.5 seconds. Very fast.
20 | - Run markdown linting: `npm run lint:md` -- takes under 1 second. Very fast.
21 | - Check formatting: `npm run format:check` -- takes 0.5 seconds. Very fast.
22 | - Fix formatting: `npm run format` -- applies Prettier formatting fixes.
23 |
24 | ### Testing and Validation
25 |
26 | - **ALWAYS** run `npm test` after making changes to rules - tests run in 5 seconds
27 | - **ALWAYS** run `npm run lint && npm run lint:md` before committing - both complete in under 3 seconds total
28 | - **ALWAYS** run `npm run format:check` to verify formatting - completes in 0.5 seconds
29 | - All validation commands are very fast - no need for long timeouts or cancellation warnings
30 |
31 | ### Manual Rule Testing
32 |
33 | You can manually test individual rules using this pattern:
34 |
35 | ```bash
36 | node -e "
37 | const rule = require('./src/rules/RULE_NAME');
38 | const {RuleTester} = require('eslint');
39 | const ruleTester = new RuleTester({
40 | parserOptions: {
41 | ecmaVersion: 'latest',
42 | sourceType: 'module',
43 | ecmaFeatures: { jsx: true }
44 | }
45 | });
46 | ruleTester.run('test', rule, {
47 | valid: [{ code: 'VALID_CODE_HERE' }],
48 | invalid: [{ code: 'INVALID_CODE_HERE', errors: [{ messageId: 'MESSAGE_ID' }] }]
49 | });
50 | "
51 | ```
52 |
53 | ## Repository Structure and Navigation
54 |
55 | ### Key Directories
56 |
57 | - `src/rules/` - ESLint rule implementations
58 | - `src/rules/__tests__/` - Jest tests for each rule using ESLint RuleTester
59 | - `docs/rules/` - Markdown documentation for each rule
60 | - `src/configs/` - ESLint configuration presets (e.g., recommended.js)
61 | - `src/utils/` - Utility functions shared across rules
62 | - `.github/workflows/` - CI pipeline definitions
63 |
64 | ### Important Files
65 |
66 | - `src/index.js` - Main entry point, exports all rules and configs
67 | - `package.json` - Scripts and dependencies (no build scripts needed)
68 | - `jest.config.js` - Jest test configuration
69 | - `.eslintrc.js` - ESLint configuration for the project itself
70 | - `.nvmrc` - Node.js version specification (v20)
71 |
72 | ### Rule Development Pattern
73 |
74 | Each rule follows this structure:
75 |
76 | 1. Rule implementation: `src/rules/rule-name.js`
77 | 2. Test file: `src/rules/__tests__/rule-name.test.js`
78 | 3. Documentation: `docs/rules/rule-name.md`
79 | 4. Export from: `src/index.js` (add to rules object)
80 | 5. Optional: Add to `src/configs/recommended.js` if should be in recommended preset
81 |
82 | ## Validation Scenarios
83 |
84 | ### After Making Rule Changes
85 |
86 | 1. Run the rule's specific test: `npm test -- --testNamePattern="rule-name"`
87 | 2. Run all tests: `npm test` (5 seconds)
88 | 3. Test the rule manually using the Node.js snippet pattern above
89 | 4. Verify the rule is exported properly from `src/index.js`
90 |
91 | ### Before Committing
92 |
93 | 1. `npm run lint` - JavaScript linting (1.5 seconds)
94 | 2. `npm run lint:md` - Markdown linting (<1 second)
95 | 3. `npm run format:check` - Formatting validation (0.5 seconds)
96 | 4. `npm test` - Full test suite (5 seconds)
97 |
98 | ### Testing Plugin Integration
99 |
100 | The plugin can be tested by:
101 |
102 | 1. Using manual Node.js rule testing (shown above)
103 | 2. Running existing test suite which validates all rules
104 | 3. Creating test files and using ESLint RuleTester in the **tests** files
105 |
106 | ## Common Development Tasks
107 |
108 | ### Adding a New Rule
109 |
110 | 1. Create rule implementation: `src/rules/new-rule-name.js`
111 | 2. Create test file: `src/rules/__tests__/new-rule-name.test.js`
112 | 3. Add to exports in `src/index.js`
113 | 4. Create documentation: `docs/rules/new-rule-name.md`
114 | 5. Optionally add to `src/configs/recommended.js`
115 | 6. Run tests: `npm test`
116 | 7. Run linting: `npm run lint`
117 |
118 | ### Modifying Existing Rules
119 |
120 | 1. Edit rule in `src/rules/rule-name.js`
121 | 2. Update tests in `src/rules/__tests__/rule-name.test.js`
122 | 3. Update documentation in `docs/rules/rule-name.md` if needed
123 | 4. Run tests: `npm test`
124 | 5. Test manually using Node.js snippet if needed
125 |
126 | ### Working with Changesets (for releases)
127 |
128 | - `npx changeset` - Create a changeset for changes
129 | - `npx changeset status` - Check changeset status
130 | - Changesets are used for versioning and publishing to npm
131 |
132 | ## Troubleshooting
133 |
134 | ### Common Issues
135 |
136 | - **Node.js version**: Use Node.js v20+ (v20 is the current standard)
137 | - **Dependencies**: Always use `npm ci` instead of `npm install` for consistent installs
138 | - **Test failures**: Run `npm test` to see specific failures - tests are fast and detailed
139 | - **Lint failures**: Run `npm run lint` and `npm run lint:md` to see specific issues
140 | - **Format issues**: Run `npm run format` to auto-fix formatting
141 |
142 | ### Rule Testing Issues
143 |
144 | - Use the RuleTester pattern shown above for manual testing
145 | - Check that messageId in tests matches the rule's meta.messages
146 | - Verify JSX parsing works by including ecmaFeatures.jsx in parserOptions
147 |
148 | ## Command Reference
149 |
150 | Essential commands and their typical execution times:
151 |
152 | - `npm ci` - Install dependencies (60 seconds)
153 | - `npm test` - Run all tests (5 seconds)
154 | - `npm run lint` - Lint JavaScript (1.5 seconds)
155 | - `npm run lint:md` - Lint Markdown (<1 second)
156 | - `npm run format:check` - Check formatting (0.5 seconds)
157 | - `npm run format` - Fix formatting (similar time)
158 |
159 | All commands except `npm ci` are very fast. No need for extended timeouts or cancellation warnings on validation commands.
160 |
--------------------------------------------------------------------------------
/src/rules/__tests__/a11y-tooltip-interactive-trigger.test.js:
--------------------------------------------------------------------------------
1 | const rule = require('../a11y-tooltip-interactive-trigger')
2 | const {RuleTester} = require('eslint')
3 |
4 | const ruleTester = new RuleTester({
5 | languageOptions: {
6 | ecmaVersion: 'latest',
7 | sourceType: 'module',
8 | parserOptions: {
9 | ecmaFeatures: {
10 | jsx: true,
11 | },
12 | },
13 | },
14 | })
15 |
16 | ruleTester.run('non-interactive-tooltip-trigger', rule, {
17 | valid: [
18 | `import {Tooltip, Button} from '@primer/react';
19 |
20 |
21 | `,
22 |
23 | `import {Tooltip, Button} from '@primer/react';
24 |
25 |
26 | `,
27 |
28 | `import {Tooltip, IconButton} from '@primer/react';
29 | import {SearchIcon} from '@primer/octicons-react';
30 |
31 |
32 | `,
33 |
34 | `import {Tooltip, Button} from '@primer/react';
35 |
36 |
37 |
38 |
39 | `,
40 |
41 | `import {Tooltip, Button} from '@primer/react';
42 |
43 |
46 | `,
47 |
48 | `import {Tooltip} from '@primer/react';
49 |
50 | see commit message
51 | `,
52 |
53 | `import {Tooltip, Link} from '@primer/react';
54 |
55 | Link
56 | `,
57 | `
58 | import {Tooltip, Link} from '@primer/react';
59 |
60 |
61 | User avatar
62 |
63 | `,
64 | `
65 | import {Tooltip, Link} from '@primer/react';
66 |
67 |
68 | Product
69 |
70 |
71 | `,
72 | `
73 | import {Tooltip, Link} from '@primer/react';
74 |
75 |
76 | Product
77 |
78 |
79 | `,
80 | `
81 | import {Tooltip, Link} from '@primer/react';
82 |
83 |
84 | Product
85 |
86 |
87 | `,
88 | ],
89 | invalid: [
90 | {
91 | code: `import {Tooltip} from '@primer/react';
92 | `,
93 | errors: [
94 | {
95 | messageId: 'singleChild',
96 | },
97 | ],
98 | },
99 | {
100 | code: `
101 | import {Tooltip} from '@primer/react';
102 |
103 | non interactive element
104 |
105 | `,
106 | errors: [
107 | {
108 | messageId: 'nonInteractiveTrigger',
109 | },
110 | ],
111 | },
112 | {
113 | code: `
114 | import {Tooltip, Button} from '@primer/react';
115 |
116 | Save
117 | `,
118 | errors: [
119 | {
120 | messageId: 'nonInteractiveTrigger',
121 | },
122 | ],
123 | },
124 | {
125 | code: `
126 | import {Tooltip} from '@primer/react';
127 |
128 | see commit message
129 | `,
130 | errors: [
131 | {
132 | messageId: 'nonInteractiveLink',
133 | },
134 | ],
135 | },
136 | {
137 | code: `
138 | import {Tooltip, Link} from '@primer/react';
139 |
140 | see commit message
141 | `,
142 | errors: [
143 | {
144 | messageId: 'nonInteractiveLink',
145 | },
146 | ],
147 | },
148 | {
149 | code: `
150 | import {Tooltip} from '@primer/react';
151 |
152 |
153 | `,
154 | errors: [
155 | {
156 | messageId: 'nonInteractiveInput',
157 | },
158 | ],
159 | },
160 | {
161 | code: `
162 | import {Tooltip, TextInput} from '@primer/react';
163 |
164 |
165 | `,
166 | errors: [
167 | {
168 | messageId: 'nonInteractiveInput',
169 | },
170 | ],
171 | },
172 | {
173 | code: `
174 | import {Tooltip, Button} from '@primer/react';
175 |
176 |
177 | `,
178 | errors: [
179 | {
180 | messageId: 'nonInteractiveTrigger',
181 | },
182 | ],
183 | },
184 | {
185 | code: `
186 | import {Tooltip, Button} from '@primer/react';
187 |
188 | Save
189 | `,
190 | errors: [
191 | {
192 | messageId: 'nonInteractiveTrigger',
193 | },
194 | ],
195 | },
196 | {
197 | code: `
198 | import {Tooltip, Button} from '@primer/react';
199 |
200 | Save
201 | `,
202 | errors: [
203 | {
204 | messageId: 'nonInteractiveInput',
205 | },
206 | ],
207 | },
208 | {
209 | code: `
210 | import {Tooltip, Button} from '@primer/react';
211 |
212 |
215 | `,
216 | errors: [
217 | {
218 | messageId: 'nonInteractiveTrigger',
219 | },
220 | ],
221 | },
222 | {
223 | code: `import {Tooltip, Button} from '@primer/react';
224 |
225 |
226 | Save
227 |
228 | `,
229 | errors: [
230 | {
231 | messageId: 'nonInteractiveLink',
232 | },
233 | ],
234 | },
235 | ],
236 | })
237 |
--------------------------------------------------------------------------------
/src/rules/a11y-tooltip-interactive-trigger.js:
--------------------------------------------------------------------------------
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' || propName(attribute))
27 | const isJSXExpression = node.value.type === 'JSXExpressionContainer' && node && typeof getPropValue(node) === 'string'
28 |
29 | return isJSXExpression
30 | }
31 |
32 | const isInteractiveAnchor = child => {
33 | const hasHref = getJSXOpeningElementAttribute(child.openingElement, 'href')
34 | const hasTo = getJSXOpeningElementAttribute(child.openingElement, 'to')
35 |
36 | if (!hasHref && !hasTo) return false
37 |
38 | const href = hasHref
39 | ? getJSXOpeningElementAttribute(child.openingElement, 'href').value.value
40 | : getJSXOpeningElementAttribute(child.openingElement, 'to').value.value
41 |
42 | const hasJSXValue = isJSXValue(child.openingElement.attributes)
43 | const isAnchorInteractive = (typeof href === 'string' && href !== '') || hasJSXValue
44 |
45 | return isAnchorInteractive
46 | }
47 |
48 | const isInputTag = el => {
49 | const openingEl = getJSXOpeningElementName(el.openingElement)
50 | return openingEl === 'input' || openingEl.toLowerCase() === 'textinput'
51 | }
52 |
53 | const isInteractiveInput = child => {
54 | const hasHiddenType =
55 | getJSXOpeningElementAttribute(child.openingElement, 'type') &&
56 | getJSXOpeningElementAttribute(child.openingElement, 'type').value.value === 'hidden'
57 | return !hasHiddenType && !hasDisabledAttr(child)
58 | }
59 |
60 | const isOtherThanAnchorOrInput = el => {
61 | return !isAnchorTag(el) && !isInputTag(el)
62 | }
63 |
64 | const getAllChildren = node => {
65 | if (Array.isArray(node.children)) {
66 | return node.children
67 | .filter(child => {
68 | return child.type === 'JSXElement'
69 | })
70 | .flatMap(child => {
71 | return [child, ...getAllChildren(child)]
72 | })
73 | }
74 | return []
75 | }
76 |
77 | const checks = [
78 | {
79 | id: 'nonInteractiveLink',
80 | filter: jsxElement => isAnchorTag(jsxElement),
81 | check: isInteractiveAnchor,
82 | },
83 | {
84 | id: 'nonInteractiveInput',
85 | filter: jsxElement => isInputTag(jsxElement),
86 | check: isInteractiveInput,
87 | },
88 | {
89 | id: 'nonInteractiveTrigger',
90 | filter: jsxElement => isOtherThanAnchorOrInput(jsxElement),
91 | check: isInteractive,
92 | },
93 | ]
94 |
95 | const checkTriggerElement = jsxNode => {
96 | const elements = [...getAllChildren(jsxNode)]
97 | const hasInteractiveElement = elements.find(element => {
98 | const openingEl = getJSXOpeningElementName(element.openingElement)
99 | if (openingEl === 'a' || openingEl === 'Link') {
100 | return isInteractiveAnchor(element)
101 | }
102 | if (openingEl === 'input' || openingEl === 'TextInput') {
103 | return isInteractiveInput(element)
104 | } else {
105 | return isInteractive(element)
106 | }
107 | })
108 |
109 | // If the tooltip has interactive elements, return.
110 | if (hasInteractiveElement) return
111 |
112 | const errors = new Set()
113 |
114 | for (const element of elements) {
115 | for (const check of checks) {
116 | if (!check.filter(element)) {
117 | continue
118 | }
119 |
120 | if (!check.check(element)) {
121 | errors.add(check.id)
122 | }
123 | }
124 | }
125 | // check the specificity of the errors. If there are multiple errors, only return the most specific one.
126 | if (errors.size > 1) {
127 | if (errors.has('nonInteractiveLink')) {
128 | errors.delete('nonInteractiveTrigger')
129 | }
130 | if (errors.has('nonInteractiveInput')) {
131 | errors.delete('nonInteractiveTrigger')
132 | }
133 | }
134 |
135 | return errors
136 | }
137 |
138 | module.exports = {
139 | meta: {
140 | type: 'problem',
141 | schema: [
142 | {
143 | properties: {
144 | skipImportCheck: {
145 | type: 'boolean',
146 | },
147 | },
148 | },
149 | ],
150 | messages: {
151 | nonInteractiveTrigger:
152 | 'Tooltips should only be applied to interactive elements that are not disabled. Consider using a `